From 3233f0693b89cecd48649fc12b150d09dafb2c8a Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 21:11:55 +0000 Subject: [PATCH 01/22] feat(task T01): implement via codex --- .env.example | 6 + .../docs/operations-credential-security.mdx | 6 +- .../content/docs/operations-server-mode.mdx | 2 +- docs-web/operations/credential-security.md | 6 +- docs-web/operations/server-mode.md | 2 +- docs/operations/credential-security.md | 8 +- docs/operations/server-mode.md | 2 +- src/app/dependency-factory/core-factory.ts | 16 +- .../security/local-file-key-provider.ts | 215 ++++++++++++++++++ .../credentials/key-provider-selection.ts | 75 ++++++ src/shared/config/code-ux-paths.ts | 5 + .../services/credential-key-providers.test.ts | 142 +++++++++++- .../headless-automation-operations.test.ts | 52 +++++ 13 files changed, 514 insertions(+), 23 deletions(-) create mode 100644 src/infrastructure/security/local-file-key-provider.ts create mode 100644 src/services/credentials/key-provider-selection.ts diff --git a/.env.example b/.env.example index 1878ee132c..d038b612b6 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,12 @@ JULES_API_KEY= DASHBOARD_PORT=4444 +# The trusted loopback dashboard automatically provisions owner-only root-key custody under ~/.code-ux. +# Headless, server, authenticated, and remote-management deployments must select an external provider. +# Supported explicit values: mounted-key-file, vault, or kms. Never put key material in this file. +# CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file +# CODE_UX_CREDENTIAL_KEY_FILE=/run/secrets/code-ux-credential-root-key + # Unfinished dashboard surfaces are visible by default in dev/test and hidden by default in production builds. # Set to true in a production build only when the surface is ready to expose. # VITE_CODEUX_FEATURE_NODES=true diff --git a/docs-web/content/docs/operations-credential-security.mdx b/docs-web/content/docs/operations-credential-security.mdx index 3c0104b783..655e7737f2 100644 --- a/docs-web/content/docs/operations-credential-security.mdx +++ b/docs-web/content/docs/operations-credential-security.mdx @@ -25,11 +25,13 @@ Authorization is rechecked after decryption. Concurrent revocation, rotation, re The SQLite secret store uses AES-256-GCM envelope encryption with a unique data key, payload nonce, and key-wrapping nonce for every write. Credential ownership and workspace context are authenticated. SQLite stores ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions—not root keys. -Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to point to a regular, owner-only mounted file containing an exact base64 or hexadecimal encoding of a 32-byte key. Electron serializes first-use key creation and atomically persists only the OS-protected blob. Vault and KMS adapters validate key material and report the active key id/version. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback. +The trusted loopback dashboard automatically provisions one raw 32-byte root key at `~/.code-ux/security/credential-root.key`, with a `0700` parent and `0600` regular file. Provisioning is exclusive, atomic, durable where filesystem synchronization is supported, and safe across concurrent startup. Symbolic links, non-files, malformed content, permissive modes, and unexpected ownership fail closed and are not repaired automatically. + +Automatic local-file custody is disabled for server mode, dashboard-disabled headless operation, authenticated or non-loopback dashboards, and remote credential management. Electron remains first priority and persists only an OS-protected blob. Explicit `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms` configuration takes priority; `CODE_UX_CREDENTIAL_KEY_FILE` alone remains a compatible mounted-file selection. Unknown values and explicit `local-file` selection are rejected. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback. ## Recovery and rotation -Back up root keys separately from `app.db`; the database alone cannot recover credentials. Creation, rotation/replacement, and promotion commit ciphertext and metadata atomically. Version compare-and-swap permits only one overlapping value change to commit. Revocation also wins against an in-flight resolution while retaining audit metadata. +Back up root keys separately from `app.db`; the database alone cannot recover credentials. Local dashboard backups must include `~/.code-ux/security/credential-root.key` with owner-only handling. Creation, rotation/replacement, and promotion commit ciphertext and metadata atomically. Version compare-and-swap permits only one overlapping value change to commit. Revocation also wins against an in-flight resolution while retaining audit metadata. Legacy global records use their first valid allowlisted project as the migrated management owner; verify that owner before expanding an old global allowlist. diff --git a/docs-web/content/docs/operations-server-mode.mdx b/docs-web/content/docs/operations-server-mode.mdx index 4838b616ef..27ba8c0fa4 100644 --- a/docs-web/content/docs/operations-server-mode.mdx +++ b/docs-web/content/docs/operations-server-mode.mdx @@ -14,7 +14,7 @@ The `credential_admin` role can still read administrative readiness, audit expor ## Probes, audit, and SLOs -`/health` is liveness. `/ready` also checks credential-key recovery, the audit store, and distributed-runner identities and returns `503` when required components are unavailable. If encrypted credential rows exist and their key cannot be recovered, startup aborts before listeners bind. +`/health` is liveness. `/ready` also checks credential-key recovery, the audit store, and distributed-runner identities and returns `503` when required components are unavailable. If encrypted credential rows exist and their key cannot be recovered, startup aborts before listeners bind. Server mode never auto-provisions local-file custody; configure `mounted-key-file`, Vault, or KMS explicitly. Authenticated operators can use `/api/admin/readiness`, `/api/admin/audit/export` (redacted NDJSON), and `/api/admin/metrics/slo`. Audit covers management calls, credential access, runs, attempts, approvals, and outbox delivery with correlation ids. diff --git a/docs-web/operations/credential-security.md b/docs-web/operations/credential-security.md index 3c0104b783..655e7737f2 100644 --- a/docs-web/operations/credential-security.md +++ b/docs-web/operations/credential-security.md @@ -25,11 +25,13 @@ Authorization is rechecked after decryption. Concurrent revocation, rotation, re The SQLite secret store uses AES-256-GCM envelope encryption with a unique data key, payload nonce, and key-wrapping nonce for every write. Credential ownership and workspace context are authenticated. SQLite stores ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions—not root keys. -Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to point to a regular, owner-only mounted file containing an exact base64 or hexadecimal encoding of a 32-byte key. Electron serializes first-use key creation and atomically persists only the OS-protected blob. Vault and KMS adapters validate key material and report the active key id/version. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback. +The trusted loopback dashboard automatically provisions one raw 32-byte root key at `~/.code-ux/security/credential-root.key`, with a `0700` parent and `0600` regular file. Provisioning is exclusive, atomic, durable where filesystem synchronization is supported, and safe across concurrent startup. Symbolic links, non-files, malformed content, permissive modes, and unexpected ownership fail closed and are not repaired automatically. + +Automatic local-file custody is disabled for server mode, dashboard-disabled headless operation, authenticated or non-loopback dashboards, and remote credential management. Electron remains first priority and persists only an OS-protected blob. Explicit `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms` configuration takes priority; `CODE_UX_CREDENTIAL_KEY_FILE` alone remains a compatible mounted-file selection. Unknown values and explicit `local-file` selection are rejected. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback. ## Recovery and rotation -Back up root keys separately from `app.db`; the database alone cannot recover credentials. Creation, rotation/replacement, and promotion commit ciphertext and metadata atomically. Version compare-and-swap permits only one overlapping value change to commit. Revocation also wins against an in-flight resolution while retaining audit metadata. +Back up root keys separately from `app.db`; the database alone cannot recover credentials. Local dashboard backups must include `~/.code-ux/security/credential-root.key` with owner-only handling. Creation, rotation/replacement, and promotion commit ciphertext and metadata atomically. Version compare-and-swap permits only one overlapping value change to commit. Revocation also wins against an in-flight resolution while retaining audit metadata. Legacy global records use their first valid allowlisted project as the migrated management owner; verify that owner before expanding an old global allowlist. diff --git a/docs-web/operations/server-mode.md b/docs-web/operations/server-mode.md index 4838b616ef..27ba8c0fa4 100644 --- a/docs-web/operations/server-mode.md +++ b/docs-web/operations/server-mode.md @@ -14,7 +14,7 @@ The `credential_admin` role can still read administrative readiness, audit expor ## Probes, audit, and SLOs -`/health` is liveness. `/ready` also checks credential-key recovery, the audit store, and distributed-runner identities and returns `503` when required components are unavailable. If encrypted credential rows exist and their key cannot be recovered, startup aborts before listeners bind. +`/health` is liveness. `/ready` also checks credential-key recovery, the audit store, and distributed-runner identities and returns `503` when required components are unavailable. If encrypted credential rows exist and their key cannot be recovered, startup aborts before listeners bind. Server mode never auto-provisions local-file custody; configure `mounted-key-file`, Vault, or KMS explicitly. Authenticated operators can use `/api/admin/readiness`, `/api/admin/audit/export` (redacted NDJSON), and `/api/admin/metrics/slo`. Audit covers management calls, credential access, runs, attempts, approvals, and outbox delivery with correlation ids. diff --git a/docs/operations/credential-security.md b/docs/operations/credential-security.md index 787f9cf673..b67f62dedc 100644 --- a/docs/operations/credential-security.md +++ b/docs/operations/credential-security.md @@ -25,13 +25,17 @@ Resolution authorization is checked both before and after decryption. If a crede The SQLite secret store uses AES-256-GCM envelope encryption. Each write generates a unique 256-bit data key, payload nonce, and key-wrapping nonce. Credential ownership and workspace context are authenticated as additional data. SQLite stores only ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions. -Root keys are never stored in SQLite. Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to identify a regular, owner-only mounted file whose contents are an exact base64 or hexadecimal encoding of 32 bytes. Oversized or permissively decodable key files are rejected. The environment variable contains a path, not key material. Keep the mount readable only by the Code UX process and outside the project workspace. +Root keys are never stored in SQLite or a project checkout. The normal loopback dashboard automatically provisions one raw 32-byte root key at `~/.code-ux/security/credential-root.key`. Its dedicated parent directory is `0700` and the regular file is `0600`. Creation uses an exclusive atomic install, durable filesystem synchronization where supported, and concurrent startup convergence so restarts recover the identical key. Existing symbolic links, non-files, malformed keys, permissive modes, or unexpected ownership are never repaired automatically; credential operations fail closed with metadata-only setup guidance. + +Automatic local-file custody is limited to the non-server dashboard with local authentication, loopback binding, and remote credential management disabled. Electron's process provider remains first priority and continues to use OS `safeStorage`. Explicit `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms` configuration takes priority over automatic custody; setting `CODE_UX_CREDENTIAL_KEY_FILE` alone remains compatible with the mounted-file provider. Unknown values and an explicit `local-file` selection are rejected. Dashboard-disabled headless operation, server mode, authenticated dashboards, non-loopback bindings, and remote credential-management deployments do not auto-provision a local key. + +For mounted-file custody, `CODE_UX_CREDENTIAL_KEY_FILE` identifies a regular, owner-only mounted file whose contents are an exact base64 or hexadecimal encoding of 32 bytes. Oversized or permissively decodable key files are rejected. The environment variable contains a path, not key material. Keep the mount readable only by the Code UX process and outside the project workspace. Electron serializes first-use root-key creation, persists only the OS-protected blob through an atomic owner-only file replacement, and refuses credential operations when `safeStorage` is unavailable. Vault and KMS adapters validate 32-byte caller-owned key material and report the active key id/version in health results. No provider silently falls back to plaintext or an insecure locally derived key. ## Recovery and rotation -Back up root keys independently from `app.db`. Losing a required key version makes its ciphertext unrecoverable by design. Restoring only SQLite is insufficient. +Back up root keys independently from `app.db`. For the normal local dashboard, back up `~/.code-ux/security/credential-root.key` while preserving owner-only handling; for external providers, retain every referenced key version. Losing a required key version makes its ciphertext unrecoverable by design. Restoring only SQLite is insufficient. Credential creation commits metadata and its first envelope in one SQLite transaction. Rotation/replacement and promotion likewise commit the new envelope, metadata, version, and rotation record atomically. Compare-and-swap guards allow only one overlapping value change to commit; losing callers must retry instead of overwriting a newer secret. Root-key providers must retain old key IDs and versions until envelopes are rewrapped. Revocation wins against in-flight resolutions and preserves audit metadata. diff --git a/docs/operations/server-mode.md b/docs/operations/server-mode.md index 984c89f951..e6a8e19cf5 100644 --- a/docs/operations/server-mode.md +++ b/docs/operations/server-mode.md @@ -105,7 +105,7 @@ Use `/ready` for runtime readiness. It reports whether the Code UX runtime finis Do not include `Authorization` headers in probe logs. The probe endpoints do not require bearer credentials. -`/ready` also reports `credentialKey`, `auditStore`, and `distributedRunner`. `/health` remains live during a key-provider outage, while `/ready` returns `503`. Startup aborts before dashboard or MCP binding when encrypted credential rows exist but their key provider cannot recover the wrapping key. Select a provider with `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms`; mounted files use `CODE_UX_CREDENTIAL_KEY_FILE` and owner-only permissions. Vault/KMS modes require their host adapter to be configured and healthy. +`/ready` also reports `credentialKey`, `auditStore`, and `distributedRunner`. `/health` remains live during a key-provider outage, while `/ready` returns `503`. Startup aborts before dashboard or MCP binding when encrypted credential rows exist but their key provider cannot recover the wrapping key. Server mode never auto-provisions local-file custody. Select a provider with `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms`; mounted files use `CODE_UX_CREDENTIAL_KEY_FILE` and owner-only permissions. Vault/KMS modes require their host adapter to be configured and healthy. Authenticated operators can inspect `/api/admin/readiness`, export redacted NDJSON from `/api/admin/audit/export`, and sample `/api/admin/metrics/slo`. Audit rows include the correlation id, principal, project, action, outcome, and redacted metadata for management requests, credential access, runs, attempts, approvals, and outbox delivery. diff --git a/src/app/dependency-factory/core-factory.ts b/src/app/dependency-factory/core-factory.ts index c2f7322ae4..f69c4d2abd 100644 --- a/src/app/dependency-factory/core-factory.ts +++ b/src/app/dependency-factory/core-factory.ts @@ -71,11 +71,8 @@ import { AutomationApprovalRepository } from "../../repositories/automation-appr import { AutomationOutboxRepository } from "../../repositories/automation-outbox-repository.js"; import { AutomationWebhookTriggerRepository } from "../../repositories/automation-webhook-trigger-repository.js"; import { CredentialBroker } from "../../services/credentials/credential-broker.js"; -import { MountedKeyFileProvider } from "../../infrastructure/security/mounted-key-file-provider.js"; import { EncryptedSqliteSecretStore } from "../../infrastructure/security/encrypted-sqlite-secret-store.js"; -import { KmsKeyProviderAdapter, VaultKeyProviderAdapter } from "../../infrastructure/security/external-key-provider-adapters.js"; -import { getProcessCredentialKeyProvider } from "../../services/credentials/key-provider-registry.js"; -import type { KeyProvider } from "../../services/credentials/key-provider.js"; +import { selectCredentialKeyProvider } from "../../services/credentials/key-provider-selection.js"; import { HeadlessAuthService, loadHeadlessSecurityConfiguration } from "../../services/headless-auth-service.js"; import { AutomationAuditExportService } from "../../services/automation-audit-export-service.js"; import { HeadlessOperationalReadinessService } from "../../services/headless-operational-readiness-service.js"; @@ -208,13 +205,10 @@ export function createCoreDependencies( const automationOutboxRepository = new AutomationOutboxRepository(appDbStorage); const automationWebhookTriggerRepository = new AutomationWebhookTriggerRepository(appDbStorage); const securityConfiguration = loadHeadlessSecurityConfiguration(); - const configuredKeyProvider = (): KeyProvider => { - const provider = process.env.CODE_UX_CREDENTIAL_KEY_PROVIDER?.trim().toLowerCase(); - if (provider === "vault") return new VaultKeyProviderAdapter(); - if (provider === "kms") return new KmsKeyProviderAdapter(); - return new MountedKeyFileProvider(process.env.CODE_UX_CREDENTIAL_KEY_FILE); - }; - const credentialKeyProvider = getProcessCredentialKeyProvider() ?? configuredKeyProvider(); + const credentialKeyProvider = selectCredentialKeyProvider({ + appConfig: options.appConfig, + security: securityConfiguration, + }); const automationAuditService = new AutomationAuditExportService(appDbStorage); const credentialBroker = new CredentialBroker( automationCredentialRepository, diff --git a/src/infrastructure/security/local-file-key-provider.ts b/src/infrastructure/security/local-file-key-provider.ts new file mode 100644 index 0000000000..1737a9b567 --- /dev/null +++ b/src/infrastructure/security/local-file-key-provider.ts @@ -0,0 +1,215 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { dirname } from "node:path"; +import { link, lstat, mkdir, open, unlink, type FileHandle } from "node:fs/promises"; +import type { CredentialBackendHealth } from "../../contracts/automation-credential-types.js"; +import { + KeyProviderUnavailableError, + type KeyMaterial, + type KeyProvider, +} from "../../services/credentials/key-provider.js"; + +const ROOT_KEY_BYTES = 32; +const DIRECTORY_MODE = 0o700; +const FILE_MODE = 0o600; +const UNSUPPORTED_FSYNC_ERROR_CODES = new Set(["EINVAL", "ENOTSUP", "EOPNOTSUPP", "EPERM", "EISDIR"]); + +function unavailable(message: string): KeyProviderUnavailableError { + return new KeyProviderUnavailableError(message); +} + +class MissingLocalKeyError extends KeyProviderUnavailableError {} + +function hasExpectedOwner(info: { uid: number }): boolean { + return typeof process.getuid !== "function" || info.uid === process.getuid(); +} + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === "ENOENT"; +} + +async function syncWhereSupported(handle: FileHandle): Promise { + try { + await handle.sync(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!code || !UNSUPPORTED_FSYNC_ERROR_CODES.has(code)) throw error; + } +} + +/** + * Persists a single raw 256-bit root key for the trusted, loopback dashboard runtime. + * Selection policy is intentionally kept outside this filesystem boundary. + */ +export class LocalFileKeyProvider implements KeyProvider { + readonly providerName = "local-file"; + + constructor( + private readonly filePath: string, + private readonly keyId = "local-file-root", + private readonly version = 1, + ) {} + + async health(): Promise { + try { + const material = await this.getActiveKey(); + material.key.fill(0); + return { + available: true, + secure: true, + provider: this.providerName, + keyId: this.keyId, + keyVersion: this.version, + }; + } catch (error) { + return { + available: false, + secure: false, + provider: this.providerName, + keyId: null, + keyVersion: null, + reason: error instanceof Error ? error.message : "Local credential root-key custody is unavailable.", + }; + } + } + + async getActiveKey(): Promise { + await this.ensureParentDirectory(); + try { + return await this.readKey(); + } catch (error) { + if (!(error instanceof MissingLocalKeyError)) throw error; + } + + await this.provision(); + return this.readKey(); + } + + async getKey(keyId: string, version: number): Promise { + if (keyId !== this.keyId || version !== this.version) { + throw unavailable("Requested local credential root-key version is unavailable."); + } + return this.getActiveKey(); + } + + private async ensureParentDirectory(): Promise { + const parentPath = dirname(this.filePath); + try { + await mkdir(parentPath, { recursive: true, mode: DIRECTORY_MODE }); + const info = await lstat(parentPath); + if (info.isSymbolicLink()) { + throw unavailable("Local credential root-key directory must not be a symbolic link."); + } + if (!info.isDirectory()) { + throw unavailable("Local credential root-key parent must be a directory."); + } + if ((info.mode & 0o777) !== DIRECTORY_MODE) { + throw unavailable("Local credential root-key directory must use owner-only permissions (0700)."); + } + if (!hasExpectedOwner(info)) { + throw unavailable("Local credential root-key directory must be owned by the current user."); + } + } catch (error) { + if (error instanceof KeyProviderUnavailableError) throw error; + throw unavailable("Local credential root-key directory is unavailable; ensure it is owner-controlled with 0700 permissions."); + } + } + + private async readKey(): Promise { + let pathInfo; + try { + pathInfo = await lstat(this.filePath); + } catch (error) { + if (isMissing(error)) { + throw new MissingLocalKeyError("Local credential root key has not been provisioned."); + } + throw unavailable("Local credential root-key file is unavailable."); + } + + if (pathInfo.isSymbolicLink()) { + throw unavailable("Local credential root-key path must not be a symbolic link."); + } + if (!pathInfo.isFile()) { + throw unavailable("Local credential root-key path must resolve to a regular file."); + } + if ((pathInfo.mode & 0o777) !== FILE_MODE) { + throw unavailable("Local credential root-key file must use owner-only permissions (0600)."); + } + if (!hasExpectedOwner(pathInfo)) { + throw unavailable("Local credential root-key file must be owned by the current user."); + } + if (pathInfo.size !== ROOT_KEY_BYTES) { + throw unavailable("Local credential root-key file is malformed; restore the original 32-byte key or configure another secure provider."); + } + + let handle: FileHandle | undefined; + try { + handle = await open(this.filePath, constants.O_RDONLY | constants.O_NOFOLLOW); + const openInfo = await handle.stat(); + if (!openInfo.isFile() || openInfo.dev !== pathInfo.dev || openInfo.ino !== pathInfo.ino) { + throw unavailable("Local credential root-key file changed during validation."); + } + if ((openInfo.mode & 0o777) !== FILE_MODE || !hasExpectedOwner(openInfo) || openInfo.size !== ROOT_KEY_BYTES) { + throw unavailable("Local credential root-key file security changed during validation."); + } + const key = await handle.readFile(); + if (key.length !== ROOT_KEY_BYTES) { + key.fill(0); + throw unavailable("Local credential root-key file is malformed; restore the original 32-byte key or configure another secure provider."); + } + return { key, keyId: this.keyId, version: this.version }; + } catch (error) { + if (error instanceof KeyProviderUnavailableError) throw error; + throw unavailable("Local credential root-key file is unavailable."); + } finally { + await handle?.close().catch(() => undefined); + } + } + + private async provision(): Promise { + const generated = randomBytes(ROOT_KEY_BYTES); + const temporaryPath = `${this.filePath}.${randomUUID()}.tmp`; + let temporaryHandle: FileHandle | undefined; + let installed = false; + try { + temporaryHandle = await open( + temporaryPath, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + FILE_MODE, + ); + await temporaryHandle.writeFile(generated); + await syncWhereSupported(temporaryHandle); + await temporaryHandle.close(); + temporaryHandle = undefined; + + try { + await link(temporaryPath, this.filePath); + installed = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + } + + if (installed) await this.syncParentDirectory(); + } catch (error) { + if (error instanceof KeyProviderUnavailableError) throw error; + throw unavailable("Local credential root key could not be provisioned; check owner-only access to the Code UX security directory."); + } finally { + generated.fill(0); + await temporaryHandle?.close().catch(() => undefined); + await unlink(temporaryPath).catch(() => undefined); + } + } + + private async syncParentDirectory(): Promise { + let directoryHandle: FileHandle | undefined; + try { + directoryHandle = await open(dirname(this.filePath), constants.O_RDONLY); + await syncWhereSupported(directoryHandle); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (!code || !UNSUPPORTED_FSYNC_ERROR_CODES.has(code)) throw error; + } finally { + await directoryHandle?.close().catch(() => undefined); + } + } +} diff --git a/src/services/credentials/key-provider-selection.ts b/src/services/credentials/key-provider-selection.ts new file mode 100644 index 0000000000..3dcd3a4de2 --- /dev/null +++ b/src/services/credentials/key-provider-selection.ts @@ -0,0 +1,75 @@ +import type { AppConfig } from "../../config/app-config.js"; +import type { HeadlessSecurityConfiguration } from "../../contracts/headless-security-types.js"; +import { KmsKeyProviderAdapter, VaultKeyProviderAdapter } from "../../infrastructure/security/external-key-provider-adapters.js"; +import { LocalFileKeyProvider } from "../../infrastructure/security/local-file-key-provider.js"; +import { MountedKeyFileProvider } from "../../infrastructure/security/mounted-key-file-provider.js"; +import { getLocalCredentialRootKeyPath } from "../../shared/config/code-ux-paths.js"; +import { getProcessCredentialKeyProvider } from "./key-provider-registry.js"; +import type { KeyProvider } from "./key-provider.js"; + +type RuntimeMode = Pick; +type SecurityMode = Pick; + +export interface KeyProviderSelectionOptions { + appConfig: RuntimeMode; + security: SecurityMode; + environment?: NodeJS.ProcessEnv; + processProvider?: KeyProvider | null; + localFilePath?: string; +} + +function dashboardHostIsLocal(hostValue: string | undefined): boolean { + const value = hostValue?.trim(); + if (!value) return true; + const normalized = value.toLowerCase(); + if (normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]") { + return true; + } + try { + const parsed = new URL(value.includes("://") ? value : `http://${value}`); + const hostname = parsed.hostname.toLowerCase(); + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"; + } catch { + return false; + } +} + +function explicitEnvironmentProvider(environment: NodeJS.ProcessEnv): KeyProvider | null { + const configured = environment.CODE_UX_CREDENTIAL_KEY_PROVIDER?.trim().toLowerCase(); + const mountedFile = environment.CODE_UX_CREDENTIAL_KEY_FILE?.trim() || undefined; + + if (!configured) return mountedFile ? new MountedKeyFileProvider(mountedFile) : null; + if (configured === "mounted-key-file") return new MountedKeyFileProvider(mountedFile); + if (configured === "vault") return new VaultKeyProviderAdapter(); + if (configured === "kms") return new KmsKeyProviderAdapter(); + if (configured === "local-file") { + throw new Error( + "CODE_UX_CREDENTIAL_KEY_PROVIDER=local-file is not allowed; local-file custody is selected automatically only for the trusted local dashboard runtime.", + ); + } + throw new Error( + `Unsupported CODE_UX_CREDENTIAL_KEY_PROVIDER value. Use mounted-key-file, vault, or kms.`, + ); +} + +export function selectCredentialKeyProvider(options: KeyProviderSelectionOptions): KeyProvider { + const processProvider = options.processProvider === undefined + ? getProcessCredentialKeyProvider() + : options.processProvider; + if (processProvider) return processProvider; + + const environment = options.environment ?? process.env; + const configuredProvider = explicitEnvironmentProvider(environment); + if (configuredProvider) return configuredProvider; + + const automaticLocalCustodyAllowed = !options.appConfig.serverMode + && options.appConfig.dashboardEnabled + && options.security.mode === "local" + && !options.security.remoteCredentialManagement + && dashboardHostIsLocal(environment.DASHBOARD_HOST); + if (automaticLocalCustodyAllowed) { + return new LocalFileKeyProvider(options.localFilePath ?? getLocalCredentialRootKeyPath()); + } + + return new MountedKeyFileProvider(undefined); +} diff --git a/src/shared/config/code-ux-paths.ts b/src/shared/config/code-ux-paths.ts index 0adf17dc82..b7a1e43f42 100644 --- a/src/shared/config/code-ux-paths.ts +++ b/src/shared/config/code-ux-paths.ts @@ -29,6 +29,11 @@ export function getHomeCodeUxPath(...segments: string[]): string { return path.join(getHomeCodeUxDir(), ...segments); } +/** Root-key custody for the local dashboard is always global, never repository-scoped. */ +export function getLocalCredentialRootKeyPath(): string { + return getHomeCodeUxPath("security", "credential-root.key"); +} + export function getCodeUxSubtasksDir(repoPath: string, sprintNumber: number): string { return getRepoCodeUxPath(repoPath, "sprints", `sprint${sprintNumber}-subtasks`); } diff --git a/tests/backend/services/credential-key-providers.test.ts b/tests/backend/services/credential-key-providers.test.ts index 953feb8f81..df883aaad0 100644 --- a/tests/backend/services/credential-key-providers.test.ts +++ b/tests/backend/services/credential-key-providers.test.ts @@ -1,11 +1,15 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { mkdtemp, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { ElectronCredentialKeyPersistence } from "../../../src/electron/credential-key-persistence.js"; import { ElectronSafeStorageKeyProvider } from "../../../src/infrastructure/security/electron-safe-storage-key-provider.js"; import { KmsKeyProviderAdapter } from "../../../src/infrastructure/security/external-key-provider-adapters.js"; +import { LocalFileKeyProvider } from "../../../src/infrastructure/security/local-file-key-provider.js"; import { MountedKeyFileProvider } from "../../../src/infrastructure/security/mounted-key-file-provider.js"; +import { selectCredentialKeyProvider } from "../../../src/services/credentials/key-provider-selection.js"; +import type { KeyProvider } from "../../../src/services/credentials/key-provider.js"; +import { getLocalCredentialRootKeyPath } from "../../../src/shared/config/code-ux-paths.js"; const dirs: string[] = []; @@ -16,10 +20,15 @@ async function tempDir(): Promise { } afterEach(async () => { + vi.unstubAllEnvs(); await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); describe("credential key providers", () => { + it("constructs local root-key custody under the global Code UX home directory", () => { + expect(getLocalCredentialRootKeyPath()).toBe(join(homedir(), ".code-ux", "security", "credential-root.key")); + }); + it("strictly validates mounted key files", async () => { const dir = await tempDir(); const validPath = join(dir, "valid.key"); @@ -107,4 +116,131 @@ describe("credential key providers", () => { firstKey.key.fill(0); secondKey.key.fill(0); }); + + it("provisions one owner-only local root key and reuses it across restarts", async () => { + const dir = await tempDir(); + const filePath = join(dir, "security", "credential-root.key"); + const selection = { + appConfig: { serverMode: false, dashboardEnabled: true }, + security: { mode: "local" as const, remoteCredentialManagement: false }, + environment: {}, + processProvider: null, + localFilePath: filePath, + }; + + const firstProvider = selectCredentialKeyProvider(selection); + await expect(firstProvider.health()).resolves.toEqual({ + available: true, + secure: true, + provider: "local-file", + keyId: "local-file-root", + keyVersion: 1, + }); + const first = await firstProvider.getActiveKey(); + const persisted = await readFile(filePath); + expect(persisted).toHaveLength(32); + expect(first.key.equals(persisted)).toBe(true); + expect((await stat(dirname(filePath))).mode & 0o777).toBe(0o700); + expect((await stat(filePath)).mode & 0o777).toBe(0o600); + + const restarted = await selectCredentialKeyProvider(selection).getActiveKey(); + expect(restarted.key.equals(first.key)).toBe(true); + first.key.fill(0); + restarted.key.fill(0); + persisted.fill(0); + }); + + it("converges concurrent local provider initialization on one durable key", async () => { + const dir = await tempDir(); + const filePath = join(dir, "security", "credential-root.key"); + const providers = Array.from({ length: 16 }, () => new LocalFileKeyProvider(filePath)); + const materials = await Promise.all(providers.map((provider) => provider.getActiveKey())); + + expect(materials.every((material) => material.key.equals(materials[0]!.key))).toBe(true); + const persisted = await readFile(filePath); + expect(persisted.equals(materials[0]!.key)).toBe(true); + materials.forEach((material) => material.key.fill(0)); + persisted.fill(0); + }); + + it("fails closed for malformed, permissive, and symbolic-link local key files", async () => { + const dir = await tempDir(); + const malformedPath = join(dir, "malformed", "credential-root.key"); + await mkdir(dirname(malformedPath), { recursive: true, mode: 0o700 }); + await writeFile(malformedPath, Buffer.alloc(16, 7), { mode: 0o600 }); + const malformedHealth = await new LocalFileKeyProvider(malformedPath).health(); + expect(malformedHealth).toMatchObject({ available: false, secure: false, provider: "local-file" }); + expect(malformedHealth.reason).toMatch(/malformed/i); + expect(malformedHealth.reason).not.toContain(dir); + expect((await stat(malformedPath)).size).toBe(16); + + const permissivePath = join(dir, "permissive", "credential-root.key"); + await mkdir(dirname(permissivePath), { recursive: true, mode: 0o700 }); + await writeFile(permissivePath, Buffer.alloc(32, 8), { mode: 0o600 }); + await chmod(permissivePath, 0o644); + await expect(new LocalFileKeyProvider(permissivePath).health()).resolves.toMatchObject({ + available: false, + secure: false, + reason: expect.stringMatching(/0600/), + }); + expect((await stat(permissivePath)).mode & 0o777).toBe(0o644); + + const linkPath = join(dir, "linked", "credential-root.key"); + const targetPath = join(dir, "target.key"); + await mkdir(dirname(linkPath), { recursive: true, mode: 0o700 }); + await writeFile(targetPath, Buffer.alloc(32, 9), { mode: 0o600 }); + await symlink(targetPath, linkPath); + await expect(new LocalFileKeyProvider(linkPath).health()).resolves.toMatchObject({ + available: false, + secure: false, + reason: expect.stringMatching(/symbolic link/), + }); + }); + + it("gives the Electron process provider precedence over environment configuration", () => { + const electronProvider = { + providerName: "electron-safe-storage", + health: vi.fn(), + getActiveKey: vi.fn(), + getKey: vi.fn(), + } as unknown as KeyProvider; + const selected = selectCredentialKeyProvider({ + appConfig: { serverMode: false, dashboardEnabled: true }, + security: { mode: "local", remoteCredentialManagement: false }, + environment: { CODE_UX_CREDENTIAL_KEY_PROVIDER: "kms" }, + processProvider: electronProvider, + }); + expect(selected).toBe(electronProvider); + }); + + it("gives supported environment providers precedence and rejects unsafe selections", () => { + const base = { + appConfig: { serverMode: false, dashboardEnabled: true }, + security: { mode: "local" as const, remoteCredentialManagement: false }, + processProvider: null, + }; + expect(selectCredentialKeyProvider({ + ...base, + environment: { CODE_UX_CREDENTIAL_KEY_PROVIDER: "kms" }, + }).providerName).toBe("kms"); + expect(selectCredentialKeyProvider({ + ...base, + environment: { CODE_UX_CREDENTIAL_KEY_FILE: "/run/secrets/root-key" }, + }).providerName).toBe("mounted-key-file"); + expect(selectCredentialKeyProvider({ + ...base, + environment: { + CODE_UX_CREDENTIAL_KEY_PROVIDER: "mounted-key-file", + CODE_UX_CREDENTIAL_KEY_FILE: "/run/secrets/root-key", + }, + }).providerName).toBe("mounted-key-file"); + expect(() => selectCredentialKeyProvider({ + ...base, + environment: { CODE_UX_CREDENTIAL_KEY_PROVIDER: "local-file" }, + })).toThrow(/not allowed/i); + expect(() => selectCredentialKeyProvider({ + ...base, + environment: { CODE_UX_CREDENTIAL_KEY_PROVIDER: "plaintext" }, + })).toThrow(/unsupported/i); + }); }); diff --git a/tests/backend/services/headless-automation-operations.test.ts b/tests/backend/services/headless-automation-operations.test.ts index 92b7160291..b412d7d8d2 100644 --- a/tests/backend/services/headless-automation-operations.test.ts +++ b/tests/backend/services/headless-automation-operations.test.ts @@ -14,6 +14,7 @@ import { HeadlessOperationalReadinessService } from "../../../src/services/headl import { DistributedNodeFlowRunnerService } from "../../../src/services/distributed-node-flow-runner-service.js"; import type { CodeUxPrincipal, HeadlessSecurityConfiguration } from "../../../src/contracts/headless-security-types.js"; import type { KeyProvider } from "../../../src/services/credentials/key-provider.js"; +import { selectCredentialKeyProvider } from "../../../src/services/credentials/key-provider-selection.js"; const temporaryDirectories: string[] = []; @@ -119,6 +120,57 @@ describe("authenticated headless automation operations", () => { expect(required.snapshot()).toMatchObject({ status: "NOT_READY", components: { credentialKey: { provider: "vault" } } }); }); + it("refuses automatic local-file custody outside the trusted local dashboard mode", async () => { + const { directory, storage } = await storageFixture(); + storage.close(); + const cases = [ + { + name: "server mode", + appConfig: { serverMode: true, dashboardEnabled: false }, + security: { mode: "service_token" as const, remoteCredentialManagement: false }, + environment: {}, + }, + { + name: "dashboard-disabled headless mode", + appConfig: { serverMode: false, dashboardEnabled: false }, + security: { mode: "local" as const, remoteCredentialManagement: false }, + environment: {}, + }, + { + name: "authenticated dashboard mode", + appConfig: { serverMode: false, dashboardEnabled: true }, + security: { mode: "trusted_proxy" as const, remoteCredentialManagement: false }, + environment: {}, + }, + { + name: "remote credential management", + appConfig: { serverMode: false, dashboardEnabled: true }, + security: { mode: "local" as const, remoteCredentialManagement: true }, + environment: {}, + }, + { + name: "non-loopback dashboard binding", + appConfig: { serverMode: false, dashboardEnabled: true }, + security: { mode: "local" as const, remoteCredentialManagement: false }, + environment: { DASHBOARD_HOST: "0.0.0.0" }, + }, + ]; + + for (const fixture of cases) { + const localFilePath = path.join(directory, fixture.name.replaceAll(" ", "-"), "credential-root.key"); + const provider = selectCredentialKeyProvider({ + appConfig: fixture.appConfig, + security: fixture.security, + environment: fixture.environment, + processProvider: null, + localFilePath, + }); + expect(provider.providerName, fixture.name).toBe("mounted-key-file"); + await expect(provider.health()).resolves.toMatchObject({ available: false, secure: true }); + await expect(fs.stat(localFilePath)).rejects.toMatchObject({ code: "ENOENT" }); + } + }); + it("grants a queued run to exactly one authorized project-scoped runner", async () => { const { directory, storage } = await storageFixture(); const project = new ProjectManagementRepository(storage).createProject({ name: "Approved local test project", sourceType: "local", sourceRef: directory }); From 813adcdcae66d36c504f4b60b1809b2d7846b386 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 21:13:17 +0000 Subject: [PATCH 02/22] feat(task T04): implement via codex --- src/contracts/custom-dashboard-types.ts | 25 ++ .../custom-dashboard-repository.ts | 300 +++++++++++++++++- src/repositories/db/app-db-migrations.ts | 7 + .../custom-dashboard-repository.test.ts | 260 ++++++++++++++- 4 files changed, 584 insertions(+), 8 deletions(-) diff --git a/src/contracts/custom-dashboard-types.ts b/src/contracts/custom-dashboard-types.ts index ec1eba55ac..2486776705 100644 --- a/src/contracts/custom-dashboard-types.ts +++ b/src/contracts/custom-dashboard-types.ts @@ -52,6 +52,22 @@ export interface CustomDashboardDataSourceNodeGraph { metadata?: CustomDashboardJsonObject; } +export type CustomDashboardCredentialPhase = "build" | "runtime"; + +export interface CustomDashboardCredentialSlotDeclaration { + slotId: string; + label: string; + phase: CustomDashboardCredentialPhase; + required: boolean; + allowedKinds: string[]; + requiredCapabilities: string[]; +} + +export interface CustomDashboardCredentialBinding { + slotId: string; + credentialId: string; +} + export interface CustomDashboardManifest { schemaVersion: number; title: string; @@ -59,6 +75,7 @@ export interface CustomDashboardManifest { filePaths: string[]; description?: string; dataSources?: CustomDashboardDataSourceNodeGraph; + credentialSlots?: CustomDashboardCredentialSlotDeclaration[]; metadata?: CustomDashboardJsonObject; } @@ -86,6 +103,8 @@ export interface CustomDashboardRecord { sourceNodeGraph: CustomDashboardDataSourceNodeGraph; styleguide: CustomDashboardJsonObject; runtimeMetadata: CustomDashboardJsonObject; + credentialBindings?: CustomDashboardCredentialBinding[]; + credentialBindingRevision?: number; publishedRevisionId: string | null; createdAt: string; updatedAt: string; @@ -103,6 +122,7 @@ export interface CustomDashboardRevisionRecord { validationStatus: CustomDashboardValidationStatus | null; validationReport: CustomDashboardValidationReport | null; runtimeMetadata: CustomDashboardJsonObject; + credentialBindings?: CustomDashboardCredentialBinding[]; validatedAt: string | null; createdAt: string; updatedAt: string; @@ -151,6 +171,11 @@ export interface CreateCustomDashboardRevisionInput { runtimeMetadata?: CustomDashboardJsonObject; } +export interface UpdateCustomDashboardCredentialBindingsInput { + expectedBindingRevision: number; + bindings: CustomDashboardCredentialBinding[]; +} + export interface CreateCustomDashboardValidationSessionInput { id?: string; status?: CustomDashboardValidationStatus; diff --git a/src/repositories/custom-dashboard-repository.ts b/src/repositories/custom-dashboard-repository.ts index d2258af47e..b5330402b8 100644 --- a/src/repositories/custom-dashboard-repository.ts +++ b/src/repositories/custom-dashboard-repository.ts @@ -3,6 +3,9 @@ import type { CreateCustomDashboardDraftInput, CreateCustomDashboardRevisionInput, CreateCustomDashboardValidationSessionInput, + CustomDashboardCredentialBinding, + CustomDashboardCredentialPhase, + CustomDashboardCredentialSlotDeclaration, CustomDashboardDataSourceEdge, CustomDashboardDataSourceNode, CustomDashboardDataSourceNodeGraph, @@ -18,6 +21,7 @@ import type { CustomDashboardValidationSessionRecord, CustomDashboardValidationStatus, UpdateCustomDashboardDraftInput, + UpdateCustomDashboardCredentialBindingsInput, UpdateCustomDashboardValidationSessionInput, } from "../contracts/custom-dashboard-types.js"; import { AppDbStorage } from "./app-db-storage.js"; @@ -35,6 +39,8 @@ interface CustomDashboardRow { source_node_graph_json: string; styleguide_json: string; runtime_metadata_json: string; + credential_bindings_json: string; + credential_binding_revision: number | string; published_revision_id: string | null; created_at: string; updated_at: string; @@ -52,6 +58,7 @@ interface CustomDashboardRevisionRow { validation_status: string | null; validation_report_json: string | null; runtime_metadata_json: string; + credential_bindings_json: string; validated_at: string | null; created_at: string; updated_at: string; @@ -89,6 +96,31 @@ const VALIDATION_STATUSES: readonly CustomDashboardValidationStatus[] = [ "cancelled", ]; +const CREDENTIAL_PHASES: readonly CustomDashboardCredentialPhase[] = ["build", "runtime"]; +const MAX_CREDENTIAL_SLOTS = 32; +const MAX_SLOT_ID_LENGTH = 64; +const MAX_SLOT_LABEL_LENGTH = 128; +const MAX_CREDENTIAL_KIND_LENGTH = 128; +const MAX_CAPABILITY_LENGTH = 128; +const MAX_CREDENTIAL_ID_LENGTH = 256; +const MAX_SLOT_LIST_ITEMS = 32; +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; +const SLOT_ID = /^[a-z][a-z0-9_-]*$/; +const CREDENTIAL_IDENTIFIER = /^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/; + +export class CustomDashboardCredentialBindingConflictError extends Error { + constructor( + public readonly dashboardId: string, + public readonly expectedBindingRevision: number, + public readonly actualBindingRevision: number, + ) { + super( + `Custom dashboard credential bindings changed concurrently for ${dashboardId}; expected revision ${expectedBindingRevision}, current revision ${actualBindingRevision}.`, + ); + this.name = "CustomDashboardCredentialBindingConflictError"; + } +} + export class CustomDashboardRepository { private readonly db: DatabaseAdapter; @@ -137,8 +169,9 @@ export class CustomDashboardRepository { this.db.prepare(` INSERT INTO custom_dashboards ( id, project_id, title, description, status, manifest_json, files_json, - source_node_graph_json, styleguide_json, runtime_metadata_json, created_at, updated_at - ) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?) + source_node_graph_json, styleguide_json, runtime_metadata_json, + credential_bindings_json, credential_binding_revision, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, '[]', 1, ?, ?) `).run( id, projectId, @@ -173,6 +206,12 @@ export class CustomDashboardRepository { const runtimeMetadata = input.runtimeMetadata === undefined ? current.runtimeMetadata : this.normalizeJsonObject(input.runtimeMetadata); + this.assertBoundCredentialPoliciesUnchanged( + current.credentialBindings ?? [], + current.manifest.credentialSlots ?? [], + manifest.credentialSlots ?? [], + ); + this.normalizeCredentialBindings(current.credentialBindings ?? [], manifest.credentialSlots ?? []); this.db.prepare(` UPDATE custom_dashboards @@ -201,6 +240,40 @@ export class CustomDashboardRepository { return this.requireDashboard(dashboardId); } + updateCredentialBindings( + dashboardId: string, + input: UpdateCustomDashboardCredentialBindingsInput, + ): CustomDashboardRecord { + const current = this.requireDashboard(dashboardId); + if (current.status === "archived") { + throw new ValidationError("Archived custom dashboards cannot update credential bindings."); + } + const expectedBindingRevision = Number(input?.expectedBindingRevision); + if (!Number.isInteger(expectedBindingRevision) || expectedBindingRevision < 1) { + throw new ValidationError("Custom dashboard expectedBindingRevision must be a positive integer."); + } + const bindings = this.normalizeCredentialBindings(input?.bindings, current.manifest.credentialSlots ?? []); + const now = new Date().toISOString(); + const update = this.db.prepare(` + UPDATE custom_dashboards + SET credential_bindings_json = ?, + credential_binding_revision = credential_binding_revision + 1, + updated_at = ? + WHERE id = ? + AND credential_binding_revision = ? + `).run(this.serializeJson(bindings), now, current.id, expectedBindingRevision); + + if (update.changes !== 1) { + const actual = this.requireDashboard(current.id).credentialBindingRevision ?? 1; + throw new CustomDashboardCredentialBindingConflictError( + current.id, + expectedBindingRevision, + actual, + ); + } + return this.requireDashboard(current.id); + } + createRevision(dashboardId: string, input: CreateCustomDashboardRevisionInput = {}): CustomDashboardRevisionRecord { const dashboard = this.requireDashboard(dashboardId); if (dashboard.status === "archived") { @@ -218,13 +291,22 @@ export class CustomDashboardRepository { const runtimeMetadata = input.runtimeMetadata === undefined ? dashboard.runtimeMetadata : this.normalizeJsonObject(input.runtimeMetadata); + const credentialBindings = this.normalizeCredentialBindings( + dashboard.credentialBindings ?? [], + manifest.credentialSlots ?? [], + ); + this.assertBoundCredentialPoliciesUnchanged( + credentialBindings, + dashboard.manifest.credentialSlots ?? [], + manifest.credentialSlots ?? [], + ); this.db.prepare(` INSERT INTO custom_dashboard_revisions ( id, dashboard_id, project_id, revision_number, manifest_json, files_json, source_node_graph_json, styleguide_json, validation_status, validation_report_json, - runtime_metadata_json, validated_at, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, NULL, ?, ?) + runtime_metadata_json, credential_bindings_json, validated_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, ?, ?, NULL, ?, ?) `).run( id, dashboard.id, @@ -235,6 +317,7 @@ export class CustomDashboardRepository { this.serializeJson(sourceNodeGraph), this.serializeJson(styleguide), this.serializeJson(runtimeMetadata), + this.serializeJson(credentialBindings), now, now, ); @@ -633,10 +716,196 @@ export class CustomDashboardRepository { filePaths, ...(input.description?.trim() ? { description: input.description.trim() } : {}), ...(input.dataSources ? { dataSources: this.normalizeSourceNodeGraph(input.dataSources) } : {}), + credentialSlots: this.normalizeCredentialSlotDeclarations(input.credentialSlots), ...(input.metadata ? { metadata: this.normalizeJsonObject(input.metadata) } : {}), }; } + private normalizeCredentialSlotDeclarations( + input: CustomDashboardCredentialSlotDeclaration[] | undefined, + ): CustomDashboardCredentialSlotDeclaration[] { + if (input === undefined) { + return []; + } + if (!Array.isArray(input)) { + throw new ValidationError("Custom dashboard manifest credentialSlots must be an array."); + } + if (input.length > MAX_CREDENTIAL_SLOTS) { + throw new ValidationError( + `Custom dashboard manifest credentialSlots cannot contain more than ${MAX_CREDENTIAL_SLOTS} entries.`, + ); + } + const slotIds = new Set(); + return input.map((declaration, index) => { + if (!declaration || typeof declaration !== "object" || Array.isArray(declaration)) { + throw new ValidationError(`Custom dashboard credential slot declaration ${index + 1} must be an object.`); + } + const slotId = this.normalizeBoundedString( + declaration.slotId, + `Custom dashboard credential slot ${index + 1} slotId`, + MAX_SLOT_ID_LENGTH, + ); + if (!SLOT_ID.test(slotId)) { + throw new ValidationError( + `Custom dashboard credential slotId must start with a lowercase letter and contain only lowercase letters, numbers, underscores, and hyphens: ${slotId}`, + ); + } + if (slotIds.has(slotId)) { + throw new ValidationError(`Custom dashboard credential slotId is duplicated: ${slotId}`); + } + slotIds.add(slotId); + const label = this.normalizeBoundedString( + declaration.label, + `Custom dashboard credential slot ${slotId} label`, + MAX_SLOT_LABEL_LENGTH, + ); + const phase = typeof declaration.phase === "string" ? declaration.phase.trim() : ""; + if (!CREDENTIAL_PHASES.includes(phase as CustomDashboardCredentialPhase)) { + throw new ValidationError(`Custom dashboard credential slot ${slotId} phase must be build or runtime.`); + } + if (typeof declaration.required !== "boolean") { + throw new ValidationError(`Custom dashboard credential slot ${slotId} required must be a boolean.`); + } + return { + slotId, + label, + phase: phase as CustomDashboardCredentialPhase, + required: declaration.required, + allowedKinds: this.normalizeCredentialIdentifierList( + declaration.allowedKinds, + `Custom dashboard credential slot ${slotId} allowedKinds`, + MAX_CREDENTIAL_KIND_LENGTH, + ), + requiredCapabilities: this.normalizeCredentialIdentifierList( + declaration.requiredCapabilities, + `Custom dashboard credential slot ${slotId} requiredCapabilities`, + MAX_CAPABILITY_LENGTH, + ), + }; + }); + } + + private normalizeCredentialBindings( + input: CustomDashboardCredentialBinding[] | undefined, + declarations: CustomDashboardCredentialSlotDeclaration[], + ): CustomDashboardCredentialBinding[] { + if (!Array.isArray(input)) { + throw new ValidationError("Custom dashboard credential bindings must be an array."); + } + if (input.length > MAX_CREDENTIAL_SLOTS) { + throw new ValidationError( + `Custom dashboard credential bindings cannot contain more than ${MAX_CREDENTIAL_SLOTS} entries.`, + ); + } + const declaredSlotIds = new Set(declarations.map((declaration) => declaration.slotId)); + const boundSlotIds = new Set(); + return input.map((binding, index) => { + if (!binding || typeof binding !== "object" || Array.isArray(binding)) { + throw new ValidationError(`Custom dashboard credential binding ${index + 1} must be an object.`); + } + const keys = Object.keys(binding); + if (keys.some((key) => key !== "slotId" && key !== "credentialId")) { + throw new ValidationError("Custom dashboard credential bindings may contain only slotId and credentialId."); + } + const slotId = this.normalizeBoundedString( + binding.slotId, + `Custom dashboard credential binding ${index + 1} slotId`, + MAX_SLOT_ID_LENGTH, + ); + if (!declaredSlotIds.has(slotId)) { + throw new ValidationError(`Custom dashboard credential binding references an undeclared slot: ${slotId}`); + } + if (boundSlotIds.has(slotId)) { + throw new ValidationError(`Custom dashboard credential binding is duplicated for slot: ${slotId}`); + } + boundSlotIds.add(slotId); + return { + slotId, + credentialId: this.normalizeBoundedString( + binding.credentialId, + `Custom dashboard credential binding ${slotId} credentialId`, + MAX_CREDENTIAL_ID_LENGTH, + ), + }; + }); + } + + private normalizeCredentialIdentifierList( + input: string[], + fieldName: string, + maxItemLength: number, + ): string[] { + if (!Array.isArray(input) || input.length === 0) { + throw new ValidationError(`${fieldName} must contain at least one value.`); + } + if (input.length > MAX_SLOT_LIST_ITEMS) { + throw new ValidationError(`${fieldName} cannot contain more than ${MAX_SLOT_LIST_ITEMS} entries.`); + } + const normalized = input.map((value, index) => { + const identifier = this.normalizeBoundedString( + value, + `${fieldName} entry ${index + 1}`, + maxItemLength, + ); + if (!CREDENTIAL_IDENTIFIER.test(identifier)) { + throw new ValidationError( + `${fieldName} entries may contain only letters, numbers, dots, underscores, colons, and hyphens.`, + ); + } + return identifier; + }); + return [...new Set(normalized)]; + } + + private assertBoundCredentialPoliciesUnchanged( + bindings: CustomDashboardCredentialBinding[], + currentDeclarations: CustomDashboardCredentialSlotDeclaration[], + nextDeclarations: CustomDashboardCredentialSlotDeclaration[], + ): void { + const currentBySlotId = new Map(currentDeclarations.map((declaration) => [declaration.slotId, declaration])); + const nextBySlotId = new Map(nextDeclarations.map((declaration) => [declaration.slotId, declaration])); + for (const binding of bindings) { + const current = currentBySlotId.get(binding.slotId); + const next = nextBySlotId.get(binding.slotId); + if (!current || !next || !this.sameCredentialSlotPolicy(current, next)) { + throw new ValidationError( + `Custom dashboard credential slot ${binding.slotId} must be unbound before its policy can change.`, + ); + } + } + } + + private sameCredentialSlotPolicy( + current: CustomDashboardCredentialSlotDeclaration, + next: CustomDashboardCredentialSlotDeclaration, + ): boolean { + return current.phase === next.phase + && current.required === next.required + && this.sameStringSet(current.allowedKinds, next.allowedKinds) + && this.sameStringSet(current.requiredCapabilities, next.requiredCapabilities); + } + + private sameStringSet(current: string[], next: string[]): boolean { + return current.length === next.length && current.every((value) => next.includes(value)); + } + + private normalizeBoundedString(value: unknown, fieldName: string, maxLength: number): string { + if (typeof value !== "string") { + throw new ValidationError(`${fieldName} must be a string.`); + } + const normalized = value.trim(); + if (!normalized) { + throw new ValidationError(`${fieldName} is required.`); + } + if (normalized.length > maxLength) { + throw new ValidationError(`${fieldName} must be at most ${maxLength} characters.`); + } + if (CONTROL_CHARACTERS.test(normalized)) { + throw new ValidationError(`${fieldName} cannot contain control characters.`); + } + return normalized; + } + private normalizeFileBundle(input: CustomDashboardFileBundle): CustomDashboardFileBundle { if (!input || typeof input !== "object" || !Array.isArray(input.files) || input.files.length === 0) { throw new ValidationError("Custom dashboard fileBundle.files must contain at least one file."); @@ -845,6 +1114,14 @@ export class CustomDashboardRepository { return this.normalizeValidationReport(parsed as CustomDashboardValidationReport); } + private parseCredentialBindings( + value: string, + declarations: CustomDashboardCredentialSlotDeclaration[], + ): CustomDashboardCredentialBinding[] { + const parsed = this.parseJson(value); + return this.normalizeCredentialBindings(parsed as CustomDashboardCredentialBinding[], declarations); + } + private parseJson(value: string): unknown { try { return JSON.parse(value) as unknown; @@ -877,17 +1154,23 @@ export class CustomDashboardRepository { } private mapDashboardRow(row: CustomDashboardRow): CustomDashboardRecord { + const manifest = this.parseManifest(row.manifest_json); return { id: row.id, projectId: row.project_id, title: row.title, description: row.description ?? "", status: this.normalizeDashboardStatus(row.status), - manifest: this.parseManifest(row.manifest_json), + manifest, fileBundle: this.parseFileBundle(row.files_json), sourceNodeGraph: this.parseSourceNodeGraph(row.source_node_graph_json), styleguide: this.parseJsonObject(row.styleguide_json), runtimeMetadata: this.parseJsonObject(row.runtime_metadata_json), + credentialBindings: this.parseCredentialBindings( + row.credential_bindings_json, + manifest.credentialSlots ?? [], + ), + credentialBindingRevision: toNumber(row.credential_binding_revision), publishedRevisionId: row.published_revision_id, createdAt: row.created_at, updatedAt: row.updated_at, @@ -895,18 +1178,23 @@ export class CustomDashboardRepository { } private mapRevisionRow(row: CustomDashboardRevisionRow): CustomDashboardRevisionRecord { + const manifest = this.parseManifest(row.manifest_json); return { id: row.id, dashboardId: row.dashboard_id, projectId: row.project_id, revisionNumber: toNumber(row.revision_number), - manifest: this.parseManifest(row.manifest_json), + manifest, fileBundle: this.parseFileBundle(row.files_json), sourceNodeGraph: this.parseSourceNodeGraph(row.source_node_graph_json), styleguide: this.parseJsonObject(row.styleguide_json), validationStatus: row.validation_status ? this.normalizeValidationStatus(row.validation_status) : null, validationReport: this.parseValidationReport(row.validation_report_json), runtimeMetadata: this.parseJsonObject(row.runtime_metadata_json), + credentialBindings: this.parseCredentialBindings( + row.credential_bindings_json, + manifest.credentialSlots ?? [], + ), validatedAt: row.validated_at, createdAt: row.created_at, updatedAt: row.updated_at, diff --git a/src/repositories/db/app-db-migrations.ts b/src/repositories/db/app-db-migrations.ts index 9a002e7b05..dd15996665 100644 --- a/src/repositories/db/app-db-migrations.ts +++ b/src/repositories/db/app-db-migrations.ts @@ -490,6 +490,8 @@ export function ensureCustomDashboardTables(db: DatabaseAdapter): void { source_node_graph_json TEXT NOT NULL, styleguide_json TEXT NOT NULL DEFAULT '{}', runtime_metadata_json TEXT NOT NULL DEFAULT '{}', + credential_bindings_json TEXT NOT NULL DEFAULT '[]', + credential_binding_revision INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE @@ -508,6 +510,7 @@ export function ensureCustomDashboardTables(db: DatabaseAdapter): void { validation_status TEXT, validation_report_json TEXT, runtime_metadata_json TEXT NOT NULL DEFAULT '{}', + credential_bindings_json TEXT NOT NULL DEFAULT '[]', validated_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -549,6 +552,10 @@ export function ensureCustomDashboardTables(db: DatabaseAdapter): void { ) `); + ensureColumn(db, "custom_dashboards", "credential_bindings_json", "TEXT NOT NULL DEFAULT '[]'"); + ensureColumn(db, "custom_dashboards", "credential_binding_revision", "INTEGER NOT NULL DEFAULT 1"); + ensureColumn(db, "custom_dashboard_revisions", "credential_bindings_json", "TEXT NOT NULL DEFAULT '[]'"); + ensureIndex(db, "idx_custom_dashboards_project_status", "custom_dashboards", "project_id, status, updated_at DESC"); ensureIndex(db, "idx_custom_dashboard_revisions_dashboard_revision", "custom_dashboard_revisions", "dashboard_id, revision_number DESC"); ensureIndex(db, "idx_custom_dashboard_revisions_project", "custom_dashboard_revisions", "project_id, created_at DESC"); diff --git a/tests/backend/repositories/custom-dashboard-repository.test.ts b/tests/backend/repositories/custom-dashboard-repository.test.ts index e4bb3c319d..b717043fb6 100644 --- a/tests/backend/repositories/custom-dashboard-repository.test.ts +++ b/tests/backend/repositories/custom-dashboard-repository.test.ts @@ -3,13 +3,18 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; import type { + CustomDashboardCredentialSlotDeclaration, CustomDashboardDataSourceNodeGraph, CustomDashboardFileBundle, CustomDashboardManifest, CustomDashboardValidationReport, } from "../../../src/contracts/custom-dashboard-types.js"; import { AppDbStorage } from "../../../src/repositories/app-db-storage.js"; -import { CustomDashboardRepository } from "../../../src/repositories/custom-dashboard-repository.js"; +import { + CustomDashboardCredentialBindingConflictError, + CustomDashboardRepository, +} from "../../../src/repositories/custom-dashboard-repository.js"; +import { ensureCustomDashboardTables } from "../../../src/repositories/db/app-db-migrations.js"; import { ProjectManagementRepository } from "../../../src/repositories/project-management-repository.js"; import { ValidationError } from "../../../src/repositories/repository-utils.js"; @@ -51,6 +56,31 @@ function manifest(title = "Delivery Pulse"): CustomDashboardManifest { }; } +function credentialSlots(): CustomDashboardCredentialSlotDeclaration[] { + return [ + { + slotId: "package_registry", + label: "Package registry", + phase: "build", + required: true, + allowedKinds: ["npm-token"], + requiredCapabilities: ["packages.read"], + }, + { + slotId: "metrics_api", + label: "Metrics API", + phase: "runtime", + required: false, + allowedKinds: ["http", "api-key"], + requiredCapabilities: ["metrics.read"], + }, + ]; +} + +function credentialManifest(title = "Delivery Pulse"): CustomDashboardManifest { + return { ...manifest(title), credentialSlots: credentialSlots() }; +} + function fileBundle(content = "export const Dashboard = () => null;"): CustomDashboardFileBundle { return { files: [ @@ -125,6 +155,186 @@ describe("CustomDashboardRepository", () => { expect(updated.runtimeMetadata).toEqual({ renderer: "preact", format: "esm" }); expect(dashboards.listDashboardsByProject(projectId).map((dashboard) => dashboard.id)).toEqual([created.id]); expect(dashboards.getDashboardById(created.id)?.manifest.metadata).toEqual({ owner: "runtime", tags: ["delivery", "quality"] }); + expect(created.manifest.credentialSlots).toEqual([]); + expect(created.credentialBindings).toEqual([]); + expect(created.credentialBindingRevision).toBe(1); + }); + + it("adds empty binding defaults to legacy rows without rewriting their dashboard data", async () => { + const { storage, dashboards, projectId } = await createFixture(); + const dashboard = dashboards.createDraft(projectId, { + title: "Legacy Dashboard", + manifest: manifest("Legacy Dashboard"), + fileBundle: fileBundle("legacy"), + }); + const revision = dashboards.createRevision(dashboard.id); + const db = storage.getDatabase(); + + db.prepare(`UPDATE custom_dashboards SET manifest_json = ? WHERE id = ?`).run( + JSON.stringify(manifest("Legacy Dashboard")), + dashboard.id, + ); + db.prepare(`UPDATE custom_dashboard_revisions SET manifest_json = ? WHERE id = ?`).run( + JSON.stringify(manifest("Legacy Dashboard")), + revision.id, + ); + db.exec(`ALTER TABLE custom_dashboards DROP COLUMN credential_bindings_json`); + db.exec(`ALTER TABLE custom_dashboards DROP COLUMN credential_binding_revision`); + db.exec(`ALTER TABLE custom_dashboard_revisions DROP COLUMN credential_bindings_json`); + + ensureCustomDashboardTables(db); + + const migratedRepository = new CustomDashboardRepository(storage); + expect(migratedRepository.getDashboardById(dashboard.id)).toMatchObject({ + id: dashboard.id, + title: "Legacy Dashboard", + manifest: { credentialSlots: [] }, + credentialBindings: [], + credentialBindingRevision: 1, + }); + expect(migratedRepository.getRevisionById(revision.id)).toMatchObject({ + id: revision.id, + manifest: { credentialSlots: [] }, + credentialBindings: [], + }); + expect(db.prepare(`SELECT files_json FROM custom_dashboards WHERE id = ?`).get(dashboard.id)).toMatchObject({ + files_json: JSON.stringify(fileBundle("legacy")), + }); + }); + + it("normalizes bounded credential slot declarations and rejects malformed declarations", async () => { + const { dashboards, projectId } = await createFixture(); + const normalized = dashboards.createDraft(projectId, { + title: "Credential Dashboard", + manifest: { + ...credentialManifest(), + credentialSlots: [{ + ...credentialSlots()[0]!, + slotId: " package_registry ", + label: " Package registry ", + phase: " build " as "build", + allowedKinds: ["npm-token", " npm-token "], + requiredCapabilities: ["packages.read", " packages.read "], + }], + }, + fileBundle: fileBundle(), + }); + expect(normalized.manifest.credentialSlots).toEqual([{ + slotId: "package_registry", + label: "Package registry", + phase: "build", + required: true, + allowedKinds: ["npm-token"], + requiredCapabilities: ["packages.read"], + }]); + + const createWithSlots = (slots: unknown): void => { + dashboards.createDraft(projectId, { + title: "Broken credential declaration", + manifest: { ...manifest(), credentialSlots: slots } as unknown as CustomDashboardManifest, + fileBundle: fileBundle(), + }); + }; + expect(() => createWithSlots([ + credentialSlots()[0], + { ...credentialSlots()[0], label: "Duplicate" }, + ])).toThrow(ValidationError); + expect(() => createWithSlots([{ ...credentialSlots()[0], phase: "deploy" }])).toThrow(ValidationError); + expect(() => createWithSlots([{ ...credentialSlots()[0], label: "x".repeat(129) }])).toThrow(ValidationError); + expect(() => createWithSlots([{ + ...credentialSlots()[0], + allowedKinds: Array.from({ length: 33 }, (_, index) => `kind-${index}`), + }])).toThrow(ValidationError); + expect(() => createWithSlots([{ + ...credentialSlots()[0], + requiredCapabilities: ["invalid capability"], + }])).toThrow(ValidationError); + expect(() => createWithSlots(Array.from({ length: 33 }, (_, index) => ({ + ...credentialSlots()[0]!, + slotId: `slot_${index}`, + })))).toThrow(ValidationError); + }); + + it("updates bindings with optimistic compare-and-swap replacement and unbinding", async () => { + const { storage, dashboards, projectId } = await createFixture(); + const dashboard = dashboards.createDraft(projectId, { + title: "Credential Dashboard", + manifest: credentialManifest(), + fileBundle: fileBundle(), + credentialBindings: [{ slotId: "metrics_api", credentialId: "bypass" }], + } as unknown as Parameters[1]); + expect(dashboard.credentialBindings).toEqual([]); + expect(dashboard.credentialBindingRevision).toBe(1); + + expect(() => dashboards.updateCredentialBindings(dashboard.id, { + expectedBindingRevision: 1, + bindings: [{ slotId: "undeclared", credentialId: "credential-a" }], + })).toThrow(ValidationError); + expect(() => dashboards.updateCredentialBindings(dashboard.id, { + expectedBindingRevision: 1, + bindings: [ + { slotId: "metrics_api", credentialId: "credential-a" }, + { slotId: "metrics_api", credentialId: "credential-b" }, + ], + })).toThrow(ValidationError); + + const bound = dashboards.updateCredentialBindings(dashboard.id, { + expectedBindingRevision: 1, + bindings: [{ slotId: "metrics_api", credentialId: "credential-a" }], + }); + expect(bound).toMatchObject({ + credentialBindings: [{ slotId: "metrics_api", credentialId: "credential-a" }], + credentialBindingRevision: 2, + }); + dashboards.updateDraft(dashboard.id, { + title: "Credential Dashboard v2", + credentialBindings: [{ slotId: "metrics_api", credentialId: "update-bypass" }], + } as unknown as Parameters[1]); + expect(dashboards.getDashboardById(dashboard.id)?.credentialBindings).toEqual([ + { slotId: "metrics_api", credentialId: "credential-a" }, + ]); + expect(() => dashboards.updateDraft(dashboard.id, { + manifest: { + ...credentialManifest("Credential Dashboard v2"), + credentialSlots: credentialSlots().map((slot) => slot.slotId === "metrics_api" + ? { ...slot, requiredCapabilities: ["metrics.write"] } + : slot), + }, + })).toThrow(ValidationError); + expect(() => dashboards.createRevision(dashboard.id, { + manifest: { + ...credentialManifest("Credential Dashboard v2"), + credentialSlots: credentialSlots().map((slot) => slot.slotId === "metrics_api" + ? { ...slot, phase: "build" } + : slot), + }, + })).toThrow(ValidationError); + + expect(() => dashboards.updateCredentialBindings(dashboard.id, { + expectedBindingRevision: 1, + bindings: [{ slotId: "metrics_api", credentialId: "stale-overwrite" }], + })).toThrow(CustomDashboardCredentialBindingConflictError); + expect(dashboards.getDashboardById(dashboard.id)?.credentialBindings).toEqual([ + { slotId: "metrics_api", credentialId: "credential-a" }, + ]); + + const replaced = dashboards.updateCredentialBindings(dashboard.id, { + expectedBindingRevision: 2, + bindings: [{ slotId: "metrics_api", credentialId: "credential-b" }], + }); + expect(replaced).toMatchObject({ + credentialBindings: [{ slotId: "metrics_api", credentialId: "credential-b" }], + credentialBindingRevision: 3, + }); + + const unbound = dashboards.updateCredentialBindings(dashboard.id, { + expectedBindingRevision: 3, + bindings: [], + }); + expect(unbound).toMatchObject({ credentialBindings: [], credentialBindingRevision: 4 }); + expect(JSON.parse((storage.getDatabase().prepare(` + SELECT credential_bindings_json FROM custom_dashboards WHERE id = ? + `).get(dashboard.id) as { credential_bindings_json: string }).credential_bindings_json)).toEqual([]); }); it("creates immutable revisions from the current draft bundle", async () => { @@ -149,6 +359,48 @@ describe("CustomDashboardRepository", () => { expect(dashboards.getDashboardById(dashboard.id)?.fileBundle.files[0]?.content).toBe("second"); }); + it("keeps revision and published binding snapshots immutable after draft rebinding", async () => { + const { dashboards, projectId } = await createFixture(); + const dashboard = dashboards.createDraft(projectId, { + title: "Credential Dashboard", + manifest: credentialManifest(), + fileBundle: fileBundle(), + }); + dashboards.updateCredentialBindings(dashboard.id, { + expectedBindingRevision: 1, + bindings: [{ slotId: "package_registry", credentialId: "credential-a" }], + }); + const publishedRevision = dashboards.markRevisionValidated( + dashboards.createRevision(dashboard.id).id, + passedReport(), + ); + dashboards.publishRevision(dashboard.id, publishedRevision.id); + + dashboards.updateCredentialBindings(dashboard.id, { + expectedBindingRevision: 2, + bindings: [{ slotId: "package_registry", credentialId: "credential-b" }], + }); + expect(dashboards.getRevisionById(publishedRevision.id)?.credentialBindings).toEqual([ + { slotId: "package_registry", credentialId: "credential-a" }, + ]); + expect(dashboards.getDashboardById(dashboard.id)).toMatchObject({ + publishedRevisionId: publishedRevision.id, + credentialBindings: [{ slotId: "package_registry", credentialId: "credential-b" }], + }); + + const replacementRevision = dashboards.createRevision(dashboard.id); + dashboards.updateCredentialBindings(dashboard.id, { + expectedBindingRevision: 3, + bindings: [], + }); + expect(dashboards.getRevisionById(replacementRevision.id)?.credentialBindings).toEqual([ + { slotId: "package_registry", credentialId: "credential-b" }, + ]); + expect(dashboards.getRevisionById(publishedRevision.id)?.credentialBindings).toEqual([ + { slotId: "package_registry", credentialId: "credential-a" }, + ]); + }); + it("tracks validation history and rejects publish attempts for unvalidated or failed revisions", async () => { const { dashboards, projectId } = await createFixture(); const dashboard = dashboards.createDraft(projectId, { @@ -323,9 +575,13 @@ describe("CustomDashboardRepository", () => { const { storage, projects, dashboards, projectId } = await createFixture(); const dashboard = dashboards.createDraft(projectId, { title: "Delivery Pulse", - manifest: manifest(), + manifest: credentialManifest(), fileBundle: fileBundle(), }); + dashboards.updateCredentialBindings(dashboard.id, { + expectedBindingRevision: 1, + bindings: [{ slotId: "metrics_api", credentialId: "credential-cleanup" }], + }); const revision = dashboards.markRevisionValidated(dashboards.createRevision(dashboard.id).id, passedReport()); dashboards.publishRevision(dashboard.id, revision.id); From 6c77cd2e8df63324186d853812492994d1548818 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 21:19:54 +0000 Subject: [PATCH 03/22] fix(task T01): address qa review via codex --- .../docs/operations-credential-security.mdx | 2 +- docs-web/operations/credential-security.md | 2 +- docs/operations/credential-security.md | 2 +- .../security/local-file-key-provider.ts | 51 ++++++++++++++----- .../services/credential-key-providers.test.ts | 18 +++++++ 5 files changed, 58 insertions(+), 17 deletions(-) diff --git a/docs-web/content/docs/operations-credential-security.mdx b/docs-web/content/docs/operations-credential-security.mdx index 655e7737f2..85c73681c5 100644 --- a/docs-web/content/docs/operations-credential-security.mdx +++ b/docs-web/content/docs/operations-credential-security.mdx @@ -25,7 +25,7 @@ Authorization is rechecked after decryption. Concurrent revocation, rotation, re The SQLite secret store uses AES-256-GCM envelope encryption with a unique data key, payload nonce, and key-wrapping nonce for every write. Credential ownership and workspace context are authenticated. SQLite stores ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions—not root keys. -The trusted loopback dashboard automatically provisions one raw 32-byte root key at `~/.code-ux/security/credential-root.key`, with a `0700` parent and `0600` regular file. Provisioning is exclusive, atomic, durable where filesystem synchronization is supported, and safe across concurrent startup. Symbolic links, non-files, malformed content, permissive modes, and unexpected ownership fail closed and are not repaired automatically. +The trusted loopback dashboard automatically provisions one raw 32-byte root key at `~/.code-ux/security/credential-root.key`, with a `0700` parent and `0600` regular file. Provisioning is exclusive, atomic, durable where filesystem synchronization is supported, and safe across concurrent startup. Every custody-path component from the Code UX home through the key parent is inspected without following symbolic links, so a symbolic-link or non-directory ancestor fails closed before a redirected key can be provisioned. Symbolic links, non-files, malformed content, permissive modes, and unexpected ownership fail closed and are not repaired automatically. Automatic local-file custody is disabled for server mode, dashboard-disabled headless operation, authenticated or non-loopback dashboards, and remote credential management. Electron remains first priority and persists only an OS-protected blob. Explicit `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms` configuration takes priority; `CODE_UX_CREDENTIAL_KEY_FILE` alone remains a compatible mounted-file selection. Unknown values and explicit `local-file` selection are rejected. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback. diff --git a/docs-web/operations/credential-security.md b/docs-web/operations/credential-security.md index 655e7737f2..85c73681c5 100644 --- a/docs-web/operations/credential-security.md +++ b/docs-web/operations/credential-security.md @@ -25,7 +25,7 @@ Authorization is rechecked after decryption. Concurrent revocation, rotation, re The SQLite secret store uses AES-256-GCM envelope encryption with a unique data key, payload nonce, and key-wrapping nonce for every write. Credential ownership and workspace context are authenticated. SQLite stores ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions—not root keys. -The trusted loopback dashboard automatically provisions one raw 32-byte root key at `~/.code-ux/security/credential-root.key`, with a `0700` parent and `0600` regular file. Provisioning is exclusive, atomic, durable where filesystem synchronization is supported, and safe across concurrent startup. Symbolic links, non-files, malformed content, permissive modes, and unexpected ownership fail closed and are not repaired automatically. +The trusted loopback dashboard automatically provisions one raw 32-byte root key at `~/.code-ux/security/credential-root.key`, with a `0700` parent and `0600` regular file. Provisioning is exclusive, atomic, durable where filesystem synchronization is supported, and safe across concurrent startup. Every custody-path component from the Code UX home through the key parent is inspected without following symbolic links, so a symbolic-link or non-directory ancestor fails closed before a redirected key can be provisioned. Symbolic links, non-files, malformed content, permissive modes, and unexpected ownership fail closed and are not repaired automatically. Automatic local-file custody is disabled for server mode, dashboard-disabled headless operation, authenticated or non-loopback dashboards, and remote credential management. Electron remains first priority and persists only an OS-protected blob. Explicit `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms` configuration takes priority; `CODE_UX_CREDENTIAL_KEY_FILE` alone remains a compatible mounted-file selection. Unknown values and explicit `local-file` selection are rejected. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback. diff --git a/docs/operations/credential-security.md b/docs/operations/credential-security.md index b67f62dedc..56bb0c0dcc 100644 --- a/docs/operations/credential-security.md +++ b/docs/operations/credential-security.md @@ -25,7 +25,7 @@ Resolution authorization is checked both before and after decryption. If a crede The SQLite secret store uses AES-256-GCM envelope encryption. Each write generates a unique 256-bit data key, payload nonce, and key-wrapping nonce. Credential ownership and workspace context are authenticated as additional data. SQLite stores only ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions. -Root keys are never stored in SQLite or a project checkout. The normal loopback dashboard automatically provisions one raw 32-byte root key at `~/.code-ux/security/credential-root.key`. Its dedicated parent directory is `0700` and the regular file is `0600`. Creation uses an exclusive atomic install, durable filesystem synchronization where supported, and concurrent startup convergence so restarts recover the identical key. Existing symbolic links, non-files, malformed keys, permissive modes, or unexpected ownership are never repaired automatically; credential operations fail closed with metadata-only setup guidance. +Root keys are never stored in SQLite or a project checkout. The normal loopback dashboard automatically provisions one raw 32-byte root key at `~/.code-ux/security/credential-root.key`. Its dedicated parent directory is `0700` and the regular file is `0600`. Creation uses an exclusive atomic install, durable filesystem synchronization where supported, and concurrent startup convergence so restarts recover the identical key. Before creation or access, every custody-path component from the Code UX home through the key parent is inspected without following symbolic links; a symbolic-link or non-directory ancestor fails closed before a redirected key can be provisioned. Existing symbolic links, non-files, malformed keys, permissive modes, or unexpected ownership are never repaired automatically; credential operations fail closed with metadata-only setup guidance. Automatic local-file custody is limited to the non-server dashboard with local authentication, loopback binding, and remote credential management disabled. Electron's process provider remains first priority and continues to use OS `safeStorage`. Explicit `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms` configuration takes priority over automatic custody; setting `CODE_UX_CREDENTIAL_KEY_FILE` alone remains compatible with the mounted-file provider. Unknown values and an explicit `local-file` selection are rejected. Dashboard-disabled headless operation, server mode, authenticated dashboards, non-loopback bindings, and remote credential-management deployments do not auto-provision a local key. diff --git a/src/infrastructure/security/local-file-key-provider.ts b/src/infrastructure/security/local-file-key-provider.ts index 1737a9b567..ce84fd4b81 100644 --- a/src/infrastructure/security/local-file-key-provider.ts +++ b/src/infrastructure/security/local-file-key-provider.ts @@ -28,6 +28,10 @@ function isMissing(error: unknown): boolean { return (error as NodeJS.ErrnoException).code === "ENOENT"; } +function alreadyExists(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === "EEXIST"; +} + async function syncWhereSupported(handle: FileHandle): Promise { try { await handle.sync(); @@ -94,27 +98,46 @@ export class LocalFileKeyProvider implements KeyProvider { private async ensureParentDirectory(): Promise { const parentPath = dirname(this.filePath); + const codeUxHomePath = dirname(parentPath); try { - await mkdir(parentPath, { recursive: true, mode: DIRECTORY_MODE }); - const info = await lstat(parentPath); - if (info.isSymbolicLink()) { - throw unavailable("Local credential root-key directory must not be a symbolic link."); - } - if (!info.isDirectory()) { - throw unavailable("Local credential root-key parent must be a directory."); - } - if ((info.mode & 0o777) !== DIRECTORY_MODE) { - throw unavailable("Local credential root-key directory must use owner-only permissions (0700)."); - } - if (!hasExpectedOwner(info)) { - throw unavailable("Local credential root-key directory must be owned by the current user."); - } + await this.createDirectoryIfMissing(codeUxHomePath); + await this.validateDirectory(codeUxHomePath, false); + await this.createDirectoryIfMissing(parentPath); + + // Revalidate the complete custody chain after creation so lstat checks each + // component itself instead of following an ancestor symlink implicitly. + await this.validateDirectory(codeUxHomePath, false); + await this.validateDirectory(parentPath, true); } catch (error) { if (error instanceof KeyProviderUnavailableError) throw error; throw unavailable("Local credential root-key directory is unavailable; ensure it is owner-controlled with 0700 permissions."); } } + private async createDirectoryIfMissing(directoryPath: string): Promise { + try { + await mkdir(directoryPath, { mode: DIRECTORY_MODE }); + } catch (error) { + if (!alreadyExists(error)) throw error; + } + } + + private async validateDirectory(directoryPath: string, requireOwnerOnlyMode: boolean): Promise { + const info = await lstat(directoryPath); + if (info.isSymbolicLink()) { + throw unavailable("Local credential root-key directory chain must not contain a symbolic link."); + } + if (!info.isDirectory()) { + throw unavailable("Local credential root-key directory chain must contain directories only."); + } + if (!hasExpectedOwner(info)) { + throw unavailable("Local credential root-key directory chain must be owned by the current user."); + } + if (requireOwnerOnlyMode && (info.mode & 0o777) !== DIRECTORY_MODE) { + throw unavailable("Local credential root-key directory must use owner-only permissions (0700)."); + } + } + private async readKey(): Promise { let pathInfo; try { diff --git a/tests/backend/services/credential-key-providers.test.ts b/tests/backend/services/credential-key-providers.test.ts index df883aaad0..2deca3f4b1 100644 --- a/tests/backend/services/credential-key-providers.test.ts +++ b/tests/backend/services/credential-key-providers.test.ts @@ -197,6 +197,24 @@ describe("credential key providers", () => { }); }); + it("rejects a symbolic-link Code UX home without provisioning at its target", async () => { + const dir = await tempDir(); + const redirectedHome = join(dir, "redirected-home"); + const linkedCodeUxHome = join(dir, "linked-code-ux-home"); + const filePath = join(linkedCodeUxHome, "security", "credential-root.key"); + const redirectedKeyPath = join(redirectedHome, "security", "credential-root.key"); + await mkdir(redirectedHome, { mode: 0o700 }); + await symlink(redirectedHome, linkedCodeUxHome); + + await expect(new LocalFileKeyProvider(filePath).health()).resolves.toMatchObject({ + available: false, + secure: false, + provider: "local-file", + reason: expect.stringMatching(/symbolic link/), + }); + await expect(stat(redirectedKeyPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("gives the Electron process provider precedence over environment configuration", () => { const electronProvider = { providerName: "electron-safe-storage", From 014aa13991163beca21265aa97482cab72c46fc5 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 21:22:35 +0000 Subject: [PATCH 04/22] feat(task T02): implement via codex --- .../settings/AutomationCredentialManager.tsx | 4 +- .../automation-credential-api.test.ts | 2 +- .../src/v2/lib/automation-credential-api.ts | 43 +- .../docs/operations-credential-security.mdx | 14 +- docs-web/operations/credential-security.md | 14 +- docs/operations/credential-security.md | 16 +- src/contracts/automation-credential-types.ts | 77 ++- .../automation-credential-repository.ts | 389 +++++++++-- src/server/automation-credential-routes.ts | 101 ++- src/server/http-errors.ts | 10 + src/server/route-utils.ts | 4 +- src/services/credentials/credential-broker.ts | 605 ++++++++++++++---- .../custom-node-runtime-service.ts | 3 +- src/services/node-flow-runtime-service.ts | 4 +- src/shared/security/redaction.ts | 1 + .../automation-credential-repository.test.ts | 87 +-- .../automation-credential-routes.test.ts | 115 +++- .../services/credential-broker.test.ts | 236 +++++++ .../services/credential-encryption.test.ts | 1 + .../credentialed-automation-e2e.test.ts | 10 +- .../node-flow-runtime-service.test.ts | 2 +- 21 files changed, 1466 insertions(+), 272 deletions(-) create mode 100644 tests/backend/services/credential-broker.test.ts diff --git a/dashboard/src/v2/components/settings/AutomationCredentialManager.tsx b/dashboard/src/v2/components/settings/AutomationCredentialManager.tsx index 607c7c34f9..2e57e56682 100644 --- a/dashboard/src/v2/components/settings/AutomationCredentialManager.tsx +++ b/dashboard/src/v2/components/settings/AutomationCredentialManager.tsx @@ -9,13 +9,13 @@ export const AutomationCredentialManager:FunctionComponent<{projectId:string}> = const [name,setName]=useState(""); const [kind,setKind]=useState(""); const [value,setValue]=useState(""); const [busy,setBusy]=useState(false); const [error,setError]=useState(null); const load=async()=>{setError(null);try{const [nextCredentials,nextHealth]=await Promise.all([fetchAutomationCredentials(projectId),fetchCredentialHealth()]);setCredentials(nextCredentials);setHealth(nextHealth);}catch(caught){setError(caught instanceof Error?caught.message:String(caught));}}; useEffect(()=>{void load();},[projectId]); - const create=async()=>{setBusy(true);setError(null);try{await createAutomationCredential(projectId,{name,kind,value,scope:"project",capabilities:["read"]});setName("");setKind("");setValue("");await load();}catch(caught){setError(caught instanceof Error?caught.message:String(caught));}finally{setBusy(false);}}; + const create=async()=>{setBusy(true);setError(null);try{await createAutomationCredential(projectId,{name,kind,value,scope:"project",allowedProjectIds:[],capabilities:["read"]});setName("");setKind("");setValue("");await load();}catch(caught){setError(caught instanceof Error?caught.message:String(caught));}finally{setBusy(false);}}; return

Automation credentials

Values are write-only and encrypted before local persistence.

{health&&!health.available?
{health.reason??"Secure key storage is unavailable. Credential writes are disabled."}
:null} {error?
{error}
:null}
-
    {credentials.map((credential)=>
  • {credential.name}
    {credential.kind} · {credential.scope} · {credential.status} · v{credential.version}
  • )}
+
    {credentials.map((credential)=>
  • {credential.name}
    {credential.kind} · {credential.scope} · {credential.status} · v{credential.version}
  • )}
; }; diff --git a/dashboard/src/v2/lib/__tests__/automation-credential-api.test.ts b/dashboard/src/v2/lib/__tests__/automation-credential-api.test.ts index cbb4410fb2..45803ff1ca 100644 --- a/dashboard/src/v2/lib/__tests__/automation-credential-api.test.ts +++ b/dashboard/src/v2/lib/__tests__/automation-credential-api.test.ts @@ -2,4 +2,4 @@ import { beforeEach,describe,expect,it,vi } from "vitest"; import { fetchJson } from "../../../lib/api/fetch-json.js"; import { createAutomationCredential,rotateAutomationCredential } from "../automation-credential-api.js"; vi.mock("../../../lib/api/fetch-json.js",()=>({fetchJson:vi.fn()})); -describe("automation credential api",()=>{beforeEach(()=>vi.clearAllMocks());it("uses write-only create and rotate endpoints",async()=>{vi.mocked(fetchJson).mockResolvedValue({});await createAutomationCredential("project/one",{name:"Token",kind:"api-token",value:"secret"});expect(fetchJson).toHaveBeenCalledWith("/api/projects/project%2Fone/credentials",expect.objectContaining({method:"POST",body:JSON.stringify({name:"Token",kind:"api-token",value:"secret"})}));await rotateAutomationCredential("project/one","credential/one","next");expect(fetchJson).toHaveBeenLastCalledWith("/api/projects/project%2Fone/credentials/credential%2Fone/rotate",expect.objectContaining({method:"POST",body:JSON.stringify({value:"next"})}));});}); +describe("automation credential api",()=>{beforeEach(()=>vi.clearAllMocks());it("uses write-only create and rotate endpoints",async()=>{vi.mocked(fetchJson).mockResolvedValue({});const createInput={name:"Token",kind:"api-token",value:"secret",scope:"project" as const,allowedProjectIds:[],capabilities:["read"]};await createAutomationCredential("project/one",createInput);expect(fetchJson).toHaveBeenCalledWith("/api/projects/project%2Fone/credentials",expect.objectContaining({method:"POST",body:JSON.stringify(createInput)}));await rotateAutomationCredential("project/one","credential/one",{value:"next",expectedVersion:1});expect(fetchJson).toHaveBeenLastCalledWith("/api/projects/project%2Fone/credentials/credential%2Fone/rotate",expect.objectContaining({method:"POST",body:JSON.stringify({value:"next",expectedVersion:1})}));});}); diff --git a/dashboard/src/v2/lib/automation-credential-api.ts b/dashboard/src/v2/lib/automation-credential-api.ts index 2c5b4d1694..7c92489e27 100644 --- a/dashboard/src/v2/lib/automation-credential-api.ts +++ b/dashboard/src/v2/lib/automation-credential-api.ts @@ -1,15 +1,36 @@ -import type { AutomationCredentialBinding, AutomationCredentialMetadata, CreateAutomationCredentialInput, CredentialBackendHealth } from "../../../../src/contracts/automation-credential-types.js"; +import type { + AutomationCredentialBinding, + AutomationCredentialCompatibilityAssessment, + AutomationCredentialMetadata, + BindAutomationCredentialInput, + CreateAutomationCredentialInput, + CredentialBackendHealth, + PromoteAutomationCredentialInput, + ReplaceAutomationCredentialSecretInput, + RestrictAutomationCredentialInput, + RevokeAutomationCredentialInput, + TestAutomationCredentialInput, + UpdateAutomationCredentialMetadataInput, +} from "../../../../src/contracts/automation-credential-types.js"; import { fetchJson } from "../../lib/api/fetch-json.js"; -const json = (method: string, body?: unknown): RequestInit => ({ method, headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) }); +const json = (method: string, body?: unknown): RequestInit => ({ + method, + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), +}); const base = (projectId: string) => `/api/projects/${encodeURIComponent(projectId)}/credentials`; +const credential = (projectId: string, id: string) => `${base(projectId)}/${encodeURIComponent(id)}`; + export const fetchCredentialHealth = (): Promise => fetchJson("/api/credentials/health"); -export const fetchAutomationCredentials = (projectId:string):Promise => fetchJson(base(projectId)); -export const createAutomationCredential = (projectId:string,input:CreateAutomationCredentialInput):Promise => fetchJson(base(projectId),json("POST",input)); -export const bindAutomationCredential = (projectId:string,id:string,input:{bindingKey:string;capabilities:string[]}):Promise => fetchJson(`${base(projectId)}/${encodeURIComponent(id)}/bind`,json("POST",input)); -export const testAutomationCredential = (projectId:string,id:string):Promise => fetchJson(`${base(projectId)}/${encodeURIComponent(id)}/test`,json("POST")); -export const rotateAutomationCredential = (projectId:string,id:string,value:string):Promise => fetchJson(`${base(projectId)}/${encodeURIComponent(id)}/rotate`,json("POST",{value})); -export const replaceAutomationCredential = (projectId:string,id:string,value:string):Promise => fetchJson(`${base(projectId)}/${encodeURIComponent(id)}/replace`,json("POST",{value})); -export const revokeAutomationCredential = (projectId:string,id:string):Promise => fetchJson(`${base(projectId)}/${encodeURIComponent(id)}/revoke`,json("POST")); -export const promoteAutomationCredential = (projectId:string,id:string,allowedProjectIds:string[]):Promise => fetchJson(`${base(projectId)}/${encodeURIComponent(id)}/promote`,json("POST",{allowedProjectIds})); -export const restrictAutomationCredential = (projectId:string,id:string,input:{allowedProjectIds:string[];capabilities:string[]}):Promise => fetchJson(`${base(projectId)}/${encodeURIComponent(id)}/restrict`,json("POST",input)); +export const fetchAutomationCredentials = (projectId: string): Promise => fetchJson(base(projectId)); +export const createAutomationCredential = (projectId: string, input: CreateAutomationCredentialInput): Promise => fetchJson(base(projectId), json("POST", input)); +export const updateAutomationCredential = (projectId: string, id: string, input: UpdateAutomationCredentialMetadataInput): Promise => fetchJson(credential(projectId, id), json("PATCH", input)); +export const bindAutomationCredential = (projectId: string, id: string, input: BindAutomationCredentialInput): Promise => fetchJson(`${credential(projectId, id)}/bind`, json("POST", input)); +export const assessAutomationCredentialCompatibility = (projectId: string, id: string, input: { allowedKinds: string[]; requiredCapabilities: string[] }): Promise => fetchJson(`${credential(projectId, id)}/compatibility`, json("POST", input)); +export const testAutomationCredential = (projectId: string, id: string, input: TestAutomationCredentialInput): Promise => fetchJson(`${credential(projectId, id)}/test`, json("POST", input)); +export const rotateAutomationCredential = (projectId: string, id: string, input: ReplaceAutomationCredentialSecretInput): Promise => fetchJson(`${credential(projectId, id)}/rotate`, json("POST", input)); +export const replaceAutomationCredential = (projectId: string, id: string, input: ReplaceAutomationCredentialSecretInput): Promise => fetchJson(`${credential(projectId, id)}/replace`, json("POST", input)); +export const revokeAutomationCredential = (projectId: string, id: string, input: RevokeAutomationCredentialInput): Promise => fetchJson(`${credential(projectId, id)}/revoke`, json("POST", input)); +export const promoteAutomationCredential = (projectId: string, id: string, input: PromoteAutomationCredentialInput): Promise => fetchJson(`${credential(projectId, id)}/promote`, json("POST", input)); +export const restrictAutomationCredential = (projectId: string, id: string, input: RestrictAutomationCredentialInput): Promise => fetchJson(`${credential(projectId, id)}/restrict`, json("POST", input)); diff --git a/docs-web/content/docs/operations-credential-security.mdx b/docs-web/content/docs/operations-credential-security.mdx index 3c0104b783..491819bb32 100644 --- a/docs-web/content/docs/operations-credential-security.mdx +++ b/docs-web/content/docs/operations-credential-security.mdx @@ -6,12 +6,14 @@ Code UX resolves canonical node credential IDs and named project binding keys th - Project credentials are owned by one project. - Global credentials require an explicit project allowlist and retain the configuring project as their management owner. Other allowlisted projects may bind and resolve the credential but cannot mutate it. -- Both the binding and credential must approve the requested capability. +- The credential kind must be allowed, and both the binding and credential must approve every declared capability before one secret read. - Revoked, unavailable, missing, cross-project, or insufficiently capable credentials fail closed. Create, rotate, and replace requests are write-only. API responses contain configuration and status metadata but never stored values. -Runtime validation bounds names, identifiers, capabilities, list counts, and secret size (64 KiB UTF-8). Malformed arrays and control characters are rejected rather than coerced. +Create requests explicitly declare kind, scope, capabilities, and an allowlist (empty for project credentials). Runtime validation bounds names, identifiers, capabilities, list counts, and secret size (64 KiB UTF-8). Malformed arrays, unknown mutation fields, and control characters are rejected rather than coerced. + +Every lifecycle mutation includes `expectedVersion`. The only mutable descriptive field is the bounded name; kind and management ownership remain immutable. Restrictions may remove allowlisted projects or capabilities but cannot add them. Project-to-global promotion is the explicit scope expansion and requires managing-project authority, `confirmScopeExpansion: true`, the current version, and an allowlist of existing projects that retains the managing project. Current-version repeated revocation is idempotent; stale versions conflict. ## Runtime redaction boundary @@ -29,12 +31,14 @@ Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to point to a regular, owne ## Recovery and rotation -Back up root keys separately from `app.db`; the database alone cannot recover credentials. Creation, rotation/replacement, and promotion commit ciphertext and metadata atomically. Version compare-and-swap permits only one overlapping value change to commit. Revocation also wins against an in-flight resolution while retaining audit metadata. +Back up root keys separately from `app.db`; the database alone cannot recover credentials. Creation, rotation/replacement, and promotion commit ciphertext and metadata atomically. Version compare-and-swap protects every lifecycle mutation. Revocation also wins against an in-flight resolution while retaining audit metadata. + +Lifecycle success and denial audits carry correlation IDs, credential IDs, and policy metadata only. Validation records `valid`, `invalid`, or `unavailable` without exposing tested values or cryptographic internals. Legacy global records use their first valid allowlisted project as the migrated management owner; verify that owner before expanding an old global allowlist. ## Dashboard API -Credential management uses project-scoped dashboard routes. List, health, and mutation responses return metadata only. Secret values are accepted only by create, rotate, and replace operations. +Credential management uses project-scoped dashboard routes. The API includes create, bounded-name update, bind, metadata-only compatibility assessment, test, rotate, replace, revoke, confirmed promotion, and monotonic restriction. Compatibility checks backend readiness, configured/active state, project access, allowed kinds, and all required capabilities without reading plaintext. List, health, compatibility, and mutation responses never contain secret values; secrets are accepted only by create, rotate, and replace operations. -Validation failures return `400`, project/management denials return `403`, and concurrent-write conflicts return `409` for a safe caller retry. +Validation failures return `400`, project/management denials return `403`, concurrent-write conflicts return `409`, invalid encrypted state returns `422`, and unavailable key custody returns `503` with a safe recovery message. diff --git a/docs-web/operations/credential-security.md b/docs-web/operations/credential-security.md index 3c0104b783..491819bb32 100644 --- a/docs-web/operations/credential-security.md +++ b/docs-web/operations/credential-security.md @@ -6,12 +6,14 @@ Code UX resolves canonical node credential IDs and named project binding keys th - Project credentials are owned by one project. - Global credentials require an explicit project allowlist and retain the configuring project as their management owner. Other allowlisted projects may bind and resolve the credential but cannot mutate it. -- Both the binding and credential must approve the requested capability. +- The credential kind must be allowed, and both the binding and credential must approve every declared capability before one secret read. - Revoked, unavailable, missing, cross-project, or insufficiently capable credentials fail closed. Create, rotate, and replace requests are write-only. API responses contain configuration and status metadata but never stored values. -Runtime validation bounds names, identifiers, capabilities, list counts, and secret size (64 KiB UTF-8). Malformed arrays and control characters are rejected rather than coerced. +Create requests explicitly declare kind, scope, capabilities, and an allowlist (empty for project credentials). Runtime validation bounds names, identifiers, capabilities, list counts, and secret size (64 KiB UTF-8). Malformed arrays, unknown mutation fields, and control characters are rejected rather than coerced. + +Every lifecycle mutation includes `expectedVersion`. The only mutable descriptive field is the bounded name; kind and management ownership remain immutable. Restrictions may remove allowlisted projects or capabilities but cannot add them. Project-to-global promotion is the explicit scope expansion and requires managing-project authority, `confirmScopeExpansion: true`, the current version, and an allowlist of existing projects that retains the managing project. Current-version repeated revocation is idempotent; stale versions conflict. ## Runtime redaction boundary @@ -29,12 +31,14 @@ Headless mode requires `CODE_UX_CREDENTIAL_KEY_FILE` to point to a regular, owne ## Recovery and rotation -Back up root keys separately from `app.db`; the database alone cannot recover credentials. Creation, rotation/replacement, and promotion commit ciphertext and metadata atomically. Version compare-and-swap permits only one overlapping value change to commit. Revocation also wins against an in-flight resolution while retaining audit metadata. +Back up root keys separately from `app.db`; the database alone cannot recover credentials. Creation, rotation/replacement, and promotion commit ciphertext and metadata atomically. Version compare-and-swap protects every lifecycle mutation. Revocation also wins against an in-flight resolution while retaining audit metadata. + +Lifecycle success and denial audits carry correlation IDs, credential IDs, and policy metadata only. Validation records `valid`, `invalid`, or `unavailable` without exposing tested values or cryptographic internals. Legacy global records use their first valid allowlisted project as the migrated management owner; verify that owner before expanding an old global allowlist. ## Dashboard API -Credential management uses project-scoped dashboard routes. List, health, and mutation responses return metadata only. Secret values are accepted only by create, rotate, and replace operations. +Credential management uses project-scoped dashboard routes. The API includes create, bounded-name update, bind, metadata-only compatibility assessment, test, rotate, replace, revoke, confirmed promotion, and monotonic restriction. Compatibility checks backend readiness, configured/active state, project access, allowed kinds, and all required capabilities without reading plaintext. List, health, compatibility, and mutation responses never contain secret values; secrets are accepted only by create, rotate, and replace operations. -Validation failures return `400`, project/management denials return `403`, and concurrent-write conflicts return `409` for a safe caller retry. +Validation failures return `400`, project/management denials return `403`, concurrent-write conflicts return `409`, invalid encrypted state returns `422`, and unavailable key custody returns `503` with a safe recovery message. diff --git a/docs/operations/credential-security.md b/docs/operations/credential-security.md index 787f9cf673..d94f52d87b 100644 --- a/docs/operations/credential-security.md +++ b/docs/operations/credential-security.md @@ -6,12 +6,16 @@ Code UX stores automation credentials through a broker rather than exposing secr - Project credentials can be managed only through their owning project. - Global credentials are opt-in and require an explicit project allowlist containing the configuring project. The configuring project remains the credential's management owner after promotion; other allowlisted projects may bind and resolve it but cannot rotate, replace, revoke, promote, or restrict it. -- Resolution succeeds only when both the credential and binding approve the requested capability. +- Resolution succeeds only when the credential kind is allowed and both the credential and binding approve every declared capability. Authorization is completed before the broker performs its single secret read. - Revoked, unavailable, missing, cross-project, or insufficiently capable credentials fail closed. The dashboard accepts secret values only on create, rotate, and replace requests. Responses contain configuration, scope, status, key-version, and validation metadata but never stored values. Access-event rows contain identifiers, binding keys, capabilities, outcomes, and denial reasons; they never contain secret material. -Management inputs are validated at runtime rather than trusted from TypeScript types. Names, kinds, binding keys, project ids, capabilities, and list counts are bounded; malformed arrays and control characters are rejected instead of being silently coerced. A stored value is limited to 64 KiB of UTF-8 data. Global allowlists must explicitly retain the management owner. +Management inputs are validated at runtime rather than trusted from TypeScript types. Create requests must explicitly declare kind, scope, capabilities, and an allowlist (an empty array for project credentials). Names, kinds, binding keys, project ids, capabilities, and list counts are bounded; malformed arrays, unknown mutation fields, and control characters are rejected instead of being silently coerced. A stored value is limited to 64 KiB of UTF-8 data. Global allowlists must explicitly retain the management owner. + +Every lifecycle mutation carries `expectedVersion`. Successful name updates, validation tests, rotations/replacements, promotions, restrictions, and first-time revocations increment the version. A repeated revoke against an already-revoked credential at its current version is an idempotent no-op; stale requests return a conflict. Metadata updates may change only the bounded display name, so kind and management ownership remain immutable. + +Restriction is monotonic: it may remove allowlisted projects or capabilities but cannot add either. Project-to-global promotion is the explicit scope expansion and requires the managing project, a current version, `confirmScopeExpansion: true`, an allowlist containing the managing project, and project IDs that already exist. ## Runtime redaction boundary @@ -33,12 +37,14 @@ Electron serializes first-use root-key creation, persists only the OS-protected Back up root keys independently from `app.db`. Losing a required key version makes its ciphertext unrecoverable by design. Restoring only SQLite is insufficient. -Credential creation commits metadata and its first envelope in one SQLite transaction. Rotation/replacement and promotion likewise commit the new envelope, metadata, version, and rotation record atomically. Compare-and-swap guards allow only one overlapping value change to commit; losing callers must retry instead of overwriting a newer secret. Root-key providers must retain old key IDs and versions until envelopes are rewrapped. Revocation wins against in-flight resolutions and preserves audit metadata. +Credential creation commits metadata and its first envelope in one SQLite transaction. Rotation/replacement and promotion likewise commit the new envelope, metadata, version, and rotation record atomically. Compare-and-swap guards apply to every lifecycle mutation so losing callers must refresh metadata and retry instead of overwriting newer state. Root-key providers must retain old key IDs and versions until envelopes are rewrapped. Revocation wins against in-flight resolutions and preserves audit metadata. + +Lifecycle successes and denials emit correlation-aware automation audit records containing credential IDs and policy metadata only. Validation updates report `valid`, `invalid`, or `unavailable` without including tested values or low-level cryptographic errors. Existing global credentials created before management ownership was stored are migrated with their first valid allowlisted project as the management owner. Operators should verify that owner before expanding a legacy global credential's allowlist. ## API surface -Project-scoped routes live under `/api/projects/:projectId/credentials`. Supported operations are create, bind, test, rotate, replace, revoke, promote, and restrict. List and health endpoints return metadata only. Existing dashboard authentication and middleware apply before these routes. +Project-scoped routes live under `/api/projects/:projectId/credentials`. Supported operations are create, bounded-name update (`PATCH /:credentialId`), bind, metadata-only compatibility assessment, test, rotate, replace, revoke, promote, and restrict. Compatibility evaluates key-backend readiness, configuration, active status, project access, allowed kinds, and all required capabilities without resolving plaintext. List, compatibility, health, and mutation responses return metadata or policy results only. Existing dashboard authentication and remote credential-management guards apply before these routes. -Runtime validation failures return `400`, project/management denials return `403`, and compare-and-swap conflicts return `409` so callers can retry without treating policy failures as server crashes. +Runtime validation failures return `400`, project/management denials return `403`, compare-and-swap conflicts return `409`, invalid encrypted state returns `422`, and unavailable key custody returns an actionable `503` response. diff --git a/src/contracts/automation-credential-types.ts b/src/contracts/automation-credential-types.ts index 5bc02e7653..78fad66eca 100644 --- a/src/contracts/automation-credential-types.ts +++ b/src/contracts/automation-credential-types.ts @@ -23,13 +23,81 @@ export interface AutomationCredentialMetadata { updatedAt: string; } +/** Secret-bearing fields in this contract are write-only and must never be serialized in a response. */ export interface CreateAutomationCredentialInput { name: string; kind: string; value: string; - scope?: AutomationCredentialScope; - allowedProjectIds?: string[]; - capabilities?: AutomationCredentialCapability[]; + scope: AutomationCredentialScope; + allowedProjectIds: string[]; + capabilities: AutomationCredentialCapability[]; +} + +export interface UpdateAutomationCredentialMetadataInput { + name: string; + expectedVersion: number; +} + +export interface ReplaceAutomationCredentialSecretInput { + value: string; + expectedVersion: number; +} + +export type RotateAutomationCredentialSecretInput = ReplaceAutomationCredentialSecretInput; + +export interface PromoteAutomationCredentialInput { + allowedProjectIds: string[]; + expectedVersion: number; + confirmScopeExpansion: boolean; +} + +export interface RestrictAutomationCredentialInput { + allowedProjectIds: string[]; + capabilities: AutomationCredentialCapability[]; + expectedVersion: number; +} + +export interface TestAutomationCredentialInput { + expectedVersion: number; +} + +export interface RevokeAutomationCredentialInput { + expectedVersion: number; +} + +export interface BindAutomationCredentialInput { + bindingKey: string; + requiredCapabilities: AutomationCredentialCapability[]; +} + +export interface AutomationCredentialCompatibilityInput { + projectId: string; + allowedKinds: string[]; + requiredCapabilities: AutomationCredentialCapability[]; +} + +export type AutomationCredentialCompatibilityIssue = + | "backend_unavailable" + | "backend_insecure" + | "not_configured" + | "not_active" + | "project_access_denied" + | "kind_not_allowed" + | "capability_missing"; + +export interface AutomationCredentialCompatibilityAssessment { + credentialId: string; + projectId: string; + compatible: boolean; + backendReady: boolean; + configured: boolean; + active: boolean; + projectAccess: boolean; + kindAllowed: boolean; + capabilitiesAllowed: boolean; + missingCapabilities: AutomationCredentialCapability[]; + issues: AutomationCredentialCompatibilityIssue[]; + metadata: AutomationCredentialMetadata | null; } export interface AutomationCredentialBinding { @@ -68,7 +136,8 @@ export interface AutomationCredentialRotation { export interface CredentialResolutionRequest { projectId: string; bindingKey: string; - capability: AutomationCredentialCapability; + requiredCapabilities: AutomationCredentialCapability[]; + allowedKinds: string[]; workspaceId: string; } diff --git a/src/repositories/automation-credential-repository.ts b/src/repositories/automation-credential-repository.ts index 1fceeb5acc..07ba3cbbb2 100644 --- a/src/repositories/automation-credential-repository.ts +++ b/src/repositories/automation-credential-repository.ts @@ -1,13 +1,58 @@ import { randomUUID } from "node:crypto"; -import type { AutomationCredentialAccessEvent, AutomationCredentialBinding, AutomationCredentialMetadata, AutomationCredentialRotation, AutomationCredentialScope, AutomationCredentialStatus } from "../contracts/automation-credential-types.js"; +import type { + AutomationCredentialAccessEvent, + AutomationCredentialBinding, + AutomationCredentialMetadata, + AutomationCredentialRotation, + AutomationCredentialScope, + AutomationCredentialStatus, +} from "../contracts/automation-credential-types.js"; +import type { StoredSecretEnvelope } from "../services/credentials/secret-store.js"; import { AppDbStorage } from "./app-db-storage.js"; import type { DatabaseAdapter } from "./db/database-adapter.js"; import { EntityNotFoundError, ValidationError, toNumber } from "./repository-utils.js"; -import type { StoredSecretEnvelope } from "../services/credentials/secret-store.js"; -interface CredentialRow { id: string; name: string; kind: string; scope: AutomationCredentialScope; project_id: string | null; management_project_id: string | null; allowed_project_ids_json: string; capabilities_json: string; status: AutomationCredentialStatus; key_id: string; key_version: number; version: number; last_validated_at: string | null; validation_status: AutomationCredentialMetadata["validationStatus"]; created_at: string; updated_at: string; configured?: number } -interface SecretRow { credential_id: string; ciphertext: Buffer; nonce: Buffer; auth_tag: Buffer; wrapped_data_key: Buffer; wrap_nonce: Buffer; wrap_auth_tag: Buffer; key_id: string; key_version: number } -interface BindingRow { id: string; credential_id: string; project_id: string; binding_key: string; required_capabilities_json: string; created_at: string; updated_at: string } +interface CredentialRow { + id: string; + name: string; + kind: string; + scope: AutomationCredentialScope; + project_id: string | null; + management_project_id: string | null; + allowed_project_ids_json: string; + capabilities_json: string; + status: AutomationCredentialStatus; + key_id: string; + key_version: number; + version: number; + last_validated_at: string | null; + validation_status: AutomationCredentialMetadata["validationStatus"]; + created_at: string; + updated_at: string; + configured?: number; +} + +interface SecretRow { + credential_id: string; + ciphertext: Buffer; + nonce: Buffer; + auth_tag: Buffer; + wrapped_data_key: Buffer; + wrap_nonce: Buffer; + wrap_auth_tag: Buffer; + key_id: string; + key_version: number; +} + +interface BindingRow { + id: string; + credential_id: string; + project_id: string; + binding_key: string; + required_capabilities_json: string; + created_at: string; + updated_at: string; +} export class CredentialConcurrentModificationError extends Error { constructor(message: string) { @@ -18,10 +63,15 @@ export class CredentialConcurrentModificationError extends Error { export class AutomationCredentialRepository { private readonly db: DatabaseAdapter; - constructor(storage: AppDbStorage = new AppDbStorage()) { this.db = storage.getDatabase(); } + + constructor(storage: AppDbStorage = new AppDbStorage()) { + this.db = storage.getDatabase(); + } requireProject(projectId: string): void { - if (!this.db.prepare("SELECT id FROM projects WHERE id = ?").get(projectId)) throw new EntityNotFoundError(`Project not found: ${projectId}`); + if (!this.db.prepare("SELECT id FROM projects WHERE id = ?").get(projectId)) { + throw new EntityNotFoundError(`Project not found: ${projectId}`); + } } countEncryptedSecrets(): number { @@ -31,27 +81,83 @@ export class AutomationCredentialRepository { list(projectId: string): AutomationCredentialMetadata[] { this.requireProject(projectId); - const rows = this.db.prepare(`SELECT c.*, EXISTS(SELECT 1 FROM automation_credential_secrets s WHERE s.credential_id=c.id) AS configured FROM automation_credentials c WHERE c.project_id = ? OR (c.scope = 'global' AND EXISTS (SELECT 1 FROM json_each(c.allowed_project_ids_json) WHERE value = ?)) ORDER BY c.updated_at DESC`).all(projectId, projectId) as CredentialRow[]; + const rows = this.db.prepare(` + SELECT c.*, EXISTS( + SELECT 1 FROM automation_credential_secrets s WHERE s.credential_id = c.id + ) AS configured + FROM automation_credentials c + WHERE c.project_id = ? OR ( + c.scope = 'global' + AND EXISTS (SELECT 1 FROM json_each(c.allowed_project_ids_json) WHERE value = ?) + ) + ORDER BY c.updated_at DESC + `).all(projectId, projectId) as CredentialRow[]; return rows.map((row) => this.mapCredential(row)); } get(id: string): AutomationCredentialMetadata | null { - const row = this.db.prepare("SELECT c.*, EXISTS(SELECT 1 FROM automation_credential_secrets s WHERE s.credential_id=c.id) AS configured FROM automation_credentials c WHERE c.id = ?").get(id) as CredentialRow | undefined; + const row = this.db.prepare(` + SELECT c.*, EXISTS( + SELECT 1 FROM automation_credential_secrets s WHERE s.credential_id = c.id + ) AS configured + FROM automation_credentials c + WHERE c.id = ? + `).get(id) as CredentialRow | undefined; return row ? this.mapCredential(row) : null; } - private create(input: { id?: string; name: string; kind: string; scope: AutomationCredentialScope; projectId: string | null; managementProjectId: string; allowedProjectIds: string[]; capabilities: string[]; keyId: string; keyVersion: number }): AutomationCredentialMetadata { + private create(input: { + id?: string; + name: string; + kind: string; + scope: AutomationCredentialScope; + projectId: string | null; + managementProjectId: string; + allowedProjectIds: string[]; + capabilities: string[]; + keyId: string; + keyVersion: number; + }): AutomationCredentialMetadata { if (input.scope === "project" && !input.projectId) throw new ValidationError("Project credentials require a projectId."); if (input.scope === "global" && input.projectId) throw new ValidationError("Global credentials cannot have an owning projectId."); if (input.projectId) this.requireProject(input.projectId); this.requireProject(input.managementProjectId); for (const projectId of input.allowedProjectIds) this.requireProject(projectId); - const id = input.id ?? randomUUID(); const now = new Date().toISOString(); - this.db.prepare(`INSERT INTO automation_credentials (id,name,kind,scope,project_id,management_project_id,allowed_project_ids_json,capabilities_json,status,key_id,key_version,version,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,'active',?,?,1,?,?)`).run(id, input.name, input.kind, input.scope, input.projectId, input.managementProjectId, JSON.stringify(input.allowedProjectIds), JSON.stringify(input.capabilities), input.keyId, input.keyVersion, now, now); + const id = input.id ?? randomUUID(); + const now = new Date().toISOString(); + this.db.prepare(` + INSERT INTO automation_credentials ( + id, name, kind, scope, project_id, management_project_id, + allowed_project_ids_json, capabilities_json, status, key_id, + key_version, version, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, 1, ?, ?) + `).run( + id, + input.name, + input.kind, + input.scope, + input.projectId, + input.managementProjectId, + JSON.stringify(input.allowedProjectIds), + JSON.stringify(input.capabilities), + input.keyId, + input.keyVersion, + now, + now, + ); return this.get(id)!; } - createWithEnvelope(input: { id: string; name: string; kind: string; scope: AutomationCredentialScope; projectId: string | null; managementProjectId: string; allowedProjectIds: string[]; capabilities: string[] }, envelope: StoredSecretEnvelope): AutomationCredentialMetadata { + createWithEnvelope(input: { + id: string; + name: string; + kind: string; + scope: AutomationCredentialScope; + projectId: string | null; + managementProjectId: string; + allowedProjectIds: string[]; + capabilities: string[]; + }, envelope: StoredSecretEnvelope): AutomationCredentialMetadata { if (envelope.credentialId !== input.id) throw new ValidationError("Credential envelope id does not match its metadata."); return this.db.transaction(() => { const created = this.create({ ...input, keyId: envelope.keyId, keyVersion: envelope.keyVersion }); @@ -60,12 +166,33 @@ export class AutomationCredentialRepository { }); } - replaceEnvelope(input: { credentialId: string; expectedVersion: number; expectedStatus: AutomationCredentialStatus; envelope: StoredSecretEnvelope; recordRotation: boolean }): AutomationCredentialMetadata { + updateMetadata(input: { credentialId: string; expectedVersion: number; name: string }): AutomationCredentialMetadata { + const result = this.db.prepare(` + UPDATE automation_credentials + SET name = ?, version = version + 1, updated_at = ? + WHERE id = ? AND version = ? + `).run(input.name, new Date().toISOString(), input.credentialId, input.expectedVersion); + this.requireCasUpdate(result.changes, input.credentialId, "metadata"); + return this.get(input.credentialId)!; + } + + replaceEnvelope(input: { + credentialId: string; + expectedVersion: number; + expectedStatus: AutomationCredentialStatus; + envelope: StoredSecretEnvelope; + recordRotation: boolean; + }): AutomationCredentialMetadata { if (input.envelope.credentialId !== input.credentialId) throw new ValidationError("Credential envelope id does not match its metadata."); return this.db.transaction(() => { const now = new Date().toISOString(); const nextVersion = input.expectedVersion + 1; - const update = this.db.prepare("UPDATE automation_credentials SET key_id=?, key_version=?, version=?, status='active', validation_status='untested', last_validated_at=NULL, updated_at=? WHERE id=? AND version=? AND status=?").run( + const update = this.db.prepare(` + UPDATE automation_credentials + SET key_id = ?, key_version = ?, version = ?, status = 'active', + validation_status = 'untested', last_validated_at = NULL, updated_at = ? + WHERE id = ? AND version = ? AND status = ? + `).run( input.envelope.keyId, input.envelope.keyVersion, nextVersion, @@ -74,7 +201,7 @@ export class AutomationCredentialRepository { input.expectedVersion, input.expectedStatus, ); - if (update.changes !== 1) throw new CredentialConcurrentModificationError("Credential changed while its value was being replaced; retry the operation."); + this.requireCasUpdate(update.changes, input.credentialId, "secret value"); this.putEnvelope(input.envelope); if (input.recordRotation) { this.recordRotation({ @@ -89,28 +216,61 @@ export class AutomationCredentialRepository { }); } - updateStatus(id: string, status: AutomationCredentialStatus): AutomationCredentialMetadata { - this.db.prepare("UPDATE automation_credentials SET status=?, updated_at=? WHERE id=?").run(status, new Date().toISOString(), id); - const result = this.get(id); if (!result) throw new EntityNotFoundError(`Credential not found: ${id}`); return result; - } - - updateValidation(id: string, status: AutomationCredentialMetadata["validationStatus"]): AutomationCredentialMetadata { - this.db.prepare("UPDATE automation_credentials SET validation_status=?, last_validated_at=?, updated_at=? WHERE id=?").run(status, new Date().toISOString(), new Date().toISOString(), id); - const result = this.get(id); if (!result) throw new EntityNotFoundError(`Credential not found: ${id}`); return result; + updateValidation(input: { + credentialId: string; + expectedVersion: number; + status: AutomationCredentialMetadata["validationStatus"]; + }): AutomationCredentialMetadata { + const now = new Date().toISOString(); + const result = this.db.prepare(` + UPDATE automation_credentials + SET validation_status = ?, last_validated_at = ?, version = version + 1, updated_at = ? + WHERE id = ? AND version = ? + `).run(input.status, now, now, input.credentialId, input.expectedVersion); + this.requireCasUpdate(result.changes, input.credentialId, "validation status"); + return this.get(input.credentialId)!; } - restrict(id: string, allowedProjectIds: string[], capabilities: string[]): AutomationCredentialMetadata { - const credential = this.get(id); if (!credential) throw new EntityNotFoundError(`Credential not found: ${id}`); - for (const projectId of allowedProjectIds) this.requireProject(projectId); - this.db.prepare("UPDATE automation_credentials SET allowed_project_ids_json=?, capabilities_json=?, updated_at=? WHERE id=?").run(JSON.stringify(allowedProjectIds), JSON.stringify(capabilities), new Date().toISOString(), id); - return this.get(id)!; + restrict(input: { + credentialId: string; + expectedVersion: number; + allowedProjectIds: string[]; + capabilities: string[]; + }): AutomationCredentialMetadata { + for (const projectId of input.allowedProjectIds) this.requireProject(projectId); + const result = this.db.prepare(` + UPDATE automation_credentials + SET allowed_project_ids_json = ?, capabilities_json = ?, version = version + 1, updated_at = ? + WHERE id = ? AND version = ? + `).run( + JSON.stringify(input.allowedProjectIds), + JSON.stringify(input.capabilities), + new Date().toISOString(), + input.credentialId, + input.expectedVersion, + ); + this.requireCasUpdate(result.changes, input.credentialId, "policy restrictions"); + return this.get(input.credentialId)!; } - promoteWithEnvelope(input: { credentialId: string; managementProjectId: string; expectedVersion: number; expectedStatus: AutomationCredentialStatus; allowedProjectIds: string[]; envelope: StoredSecretEnvelope }): AutomationCredentialMetadata { + promoteWithEnvelope(input: { + credentialId: string; + managementProjectId: string; + expectedVersion: number; + expectedStatus: AutomationCredentialStatus; + allowedProjectIds: string[]; + envelope: StoredSecretEnvelope; + }): AutomationCredentialMetadata { if (input.envelope.credentialId !== input.credentialId) throw new ValidationError("Credential envelope id does not match its metadata."); for (const projectId of input.allowedProjectIds) this.requireProject(projectId); return this.db.transaction(() => { - const update = this.db.prepare("UPDATE automation_credentials SET scope='global', project_id=NULL, allowed_project_ids_json=?, key_id=?, key_version=?, updated_at=? WHERE id=? AND scope='project' AND project_id=? AND management_project_id=? AND version=? AND status=?").run( + const update = this.db.prepare(` + UPDATE automation_credentials + SET scope = 'global', project_id = NULL, allowed_project_ids_json = ?, + key_id = ?, key_version = ?, version = version + 1, updated_at = ? + WHERE id = ? AND scope = 'project' AND project_id = ? + AND management_project_id = ? AND version = ? AND status = ? + `).run( JSON.stringify(input.allowedProjectIds), input.envelope.keyId, input.envelope.keyVersion, @@ -121,26 +281,171 @@ export class AutomationCredentialRepository { input.expectedVersion, input.expectedStatus, ); - if (update.changes !== 1) throw new CredentialConcurrentModificationError("Credential changed while it was being promoted; retry the operation."); + this.requireCasUpdate(update.changes, input.credentialId, "scope promotion"); this.putEnvelope(input.envelope); return this.get(input.credentialId)!; }); } + revoke(input: { credentialId: string; expectedVersion: number }): AutomationCredentialMetadata { + return this.db.transaction(() => { + const current = this.get(input.credentialId); + if (!current) throw new EntityNotFoundError(`Credential not found: ${input.credentialId}`); + if (current.version !== input.expectedVersion) { + throw this.concurrentModification("revocation"); + } + if (current.status === "revoked") return current; + const result = this.db.prepare(` + UPDATE automation_credentials + SET status = 'revoked', version = version + 1, updated_at = ? + WHERE id = ? AND version = ? AND status != 'revoked' + `).run(new Date().toISOString(), input.credentialId, input.expectedVersion); + this.requireCasUpdate(result.changes, input.credentialId, "revocation"); + return this.get(input.credentialId)!; + }); + } + private putEnvelope(envelope: StoredSecretEnvelope): void { - this.db.prepare(`INSERT INTO automation_credential_secrets (credential_id,ciphertext,nonce,auth_tag,wrapped_data_key,wrap_nonce,wrap_auth_tag,key_id,key_version,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?) ON CONFLICT(credential_id) DO UPDATE SET ciphertext=excluded.ciphertext,nonce=excluded.nonce,auth_tag=excluded.auth_tag,wrapped_data_key=excluded.wrapped_data_key,wrap_nonce=excluded.wrap_nonce,wrap_auth_tag=excluded.wrap_auth_tag,key_id=excluded.key_id,key_version=excluded.key_version,updated_at=excluded.updated_at`).run(envelope.credentialId,envelope.ciphertext,envelope.nonce,envelope.authTag,envelope.wrappedDataKey,envelope.wrapNonce,envelope.wrapAuthTag,envelope.keyId,envelope.keyVersion,new Date().toISOString()); + this.db.prepare(` + INSERT INTO automation_credential_secrets ( + credential_id, ciphertext, nonce, auth_tag, wrapped_data_key, + wrap_nonce, wrap_auth_tag, key_id, key_version, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(credential_id) DO UPDATE SET + ciphertext = excluded.ciphertext, + nonce = excluded.nonce, + auth_tag = excluded.auth_tag, + wrapped_data_key = excluded.wrapped_data_key, + wrap_nonce = excluded.wrap_nonce, + wrap_auth_tag = excluded.wrap_auth_tag, + key_id = excluded.key_id, + key_version = excluded.key_version, + updated_at = excluded.updated_at + `).run( + envelope.credentialId, + envelope.ciphertext, + envelope.nonce, + envelope.authTag, + envelope.wrappedDataKey, + envelope.wrapNonce, + envelope.wrapAuthTag, + envelope.keyId, + envelope.keyVersion, + new Date().toISOString(), + ); + } + + getEnvelope(credentialId: string): StoredSecretEnvelope | null { + const row = this.db.prepare("SELECT * FROM automation_credential_secrets WHERE credential_id = ?").get(credentialId) as SecretRow | undefined; + return row ? { + credentialId: row.credential_id, + ciphertext: row.ciphertext, + nonce: row.nonce, + authTag: row.auth_tag, + wrappedDataKey: row.wrapped_data_key, + wrapNonce: row.wrap_nonce, + wrapAuthTag: row.wrap_auth_tag, + keyId: row.key_id, + keyVersion: toNumber(row.key_version), + } : null; } - getEnvelope(credentialId: string): StoredSecretEnvelope | null { const row=this.db.prepare("SELECT * FROM automation_credential_secrets WHERE credential_id=?").get(credentialId) as SecretRow|undefined; return row ? {credentialId:row.credential_id,ciphertext:row.ciphertext,nonce:row.nonce,authTag:row.auth_tag,wrappedDataKey:row.wrapped_data_key,wrapNonce:row.wrap_nonce,wrapAuthTag:row.wrap_auth_tag,keyId:row.key_id,keyVersion:toNumber(row.key_version)} : null; } bind(credentialId: string, projectId: string, bindingKey: string, requiredCapabilities: string[]): AutomationCredentialBinding { - this.requireProject(projectId); const now=new Date().toISOString(); const id=randomUUID(); - this.db.prepare(`INSERT INTO automation_credential_bindings (id,credential_id,project_id,binding_key,required_capabilities_json,created_at,updated_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(project_id,binding_key) DO UPDATE SET credential_id=excluded.credential_id,required_capabilities_json=excluded.required_capabilities_json,updated_at=excluded.updated_at`).run(id,credentialId,projectId,bindingKey,JSON.stringify(requiredCapabilities),now,now); - return this.getBinding(projectId,bindingKey)!; + this.requireProject(projectId); + const now = new Date().toISOString(); + const id = randomUUID(); + this.db.prepare(` + INSERT INTO automation_credential_bindings ( + id, credential_id, project_id, binding_key, required_capabilities_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, binding_key) DO UPDATE SET + credential_id = excluded.credential_id, + required_capabilities_json = excluded.required_capabilities_json, + updated_at = excluded.updated_at + `).run(id, credentialId, projectId, bindingKey, JSON.stringify(requiredCapabilities), now, now); + return this.getBinding(projectId, bindingKey)!; + } + + getBinding(projectId: string, bindingKey: string): AutomationCredentialBinding | null { + const row = this.db.prepare("SELECT * FROM automation_credential_bindings WHERE project_id = ? AND binding_key = ?").get(projectId, bindingKey) as BindingRow | undefined; + return row ? this.mapBinding(row) : null; + } + + recordAccess(input: Omit): void { + this.db.prepare(` + INSERT INTO automation_credential_access_events ( + id, credential_id, project_id, binding_key, capability, operation, outcome, reason, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + randomUUID(), + input.credentialId, + input.projectId, + input.bindingKey, + input.capability, + input.operation, + input.outcome, + input.reason, + new Date().toISOString(), + ); } - getBinding(projectId: string, bindingKey: string): AutomationCredentialBinding | null { const row=this.db.prepare("SELECT * FROM automation_credential_bindings WHERE project_id=? AND binding_key=?").get(projectId,bindingKey) as BindingRow|undefined; return row ? this.mapBinding(row):null; } - recordAccess(input: Omit): void { this.db.prepare(`INSERT INTO automation_credential_access_events (id,credential_id,project_id,binding_key,capability,operation,outcome,reason,created_at) VALUES (?,?,?,?,?,?,?,?,?)`).run(randomUUID(),input.credentialId,input.projectId,input.bindingKey,input.capability,input.operation,input.outcome,input.reason,new Date().toISOString()); } - recordRotation(input: Omit): void { this.db.prepare(`INSERT INTO automation_credential_rotations (id,credential_id,from_version,to_version,key_id,key_version,rotated_at) VALUES (?,?,?,?,?,?,?)`).run(randomUUID(),input.credentialId,input.fromVersion,input.toVersion,input.keyId,input.keyVersion,new Date().toISOString()); } - private mapCredential(row: CredentialRow): AutomationCredentialMetadata { return {id:row.id,name:row.name,kind:row.kind,scope:row.scope,projectId:row.project_id,managementProjectId:row.management_project_id,allowedProjectIds:JSON.parse(row.allowed_project_ids_json) as string[],capabilities:JSON.parse(row.capabilities_json) as string[],status:row.status,configured:toNumber(row.configured)===1,keyId:row.key_id,keyVersion:toNumber(row.key_version),version:toNumber(row.version),lastValidatedAt:row.last_validated_at,validationStatus:row.validation_status,createdAt:row.created_at,updatedAt:row.updated_at}; } - private mapBinding(row: BindingRow): AutomationCredentialBinding { return {id:row.id,credentialId:row.credential_id,projectId:row.project_id,bindingKey:row.binding_key,requiredCapabilities:JSON.parse(row.required_capabilities_json) as string[],createdAt:row.created_at,updatedAt:row.updated_at}; } + recordRotation(input: Omit): void { + this.db.prepare(` + INSERT INTO automation_credential_rotations ( + id, credential_id, from_version, to_version, key_id, key_version, rotated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + randomUUID(), + input.credentialId, + input.fromVersion, + input.toVersion, + input.keyId, + input.keyVersion, + new Date().toISOString(), + ); + } + + private requireCasUpdate(changes: number, credentialId: string, operation: string): void { + if (changes === 1) return; + if (!this.get(credentialId)) throw new EntityNotFoundError(`Credential not found: ${credentialId}`); + throw this.concurrentModification(operation); + } + + private concurrentModification(operation: string): CredentialConcurrentModificationError { + return new CredentialConcurrentModificationError(`Credential changed while ${operation} was being applied; refresh its metadata and retry with the current version.`); + } + + private mapCredential(row: CredentialRow): AutomationCredentialMetadata { + return { + id: row.id, + name: row.name, + kind: row.kind, + scope: row.scope, + projectId: row.project_id, + managementProjectId: row.management_project_id, + allowedProjectIds: JSON.parse(row.allowed_project_ids_json) as string[], + capabilities: JSON.parse(row.capabilities_json) as string[], + status: row.status, + configured: toNumber(row.configured) === 1, + keyId: row.key_id, + keyVersion: toNumber(row.key_version), + version: toNumber(row.version), + lastValidatedAt: row.last_validated_at, + validationStatus: row.validation_status, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } + + private mapBinding(row: BindingRow): AutomationCredentialBinding { + return { + id: row.id, + credentialId: row.credential_id, + projectId: row.project_id, + bindingKey: row.binding_key, + requiredCapabilities: JSON.parse(row.required_capabilities_json) as string[], + createdAt: row.created_at, + updatedAt: row.updated_at, + }; + } } diff --git a/src/server/automation-credential-routes.ts b/src/server/automation-credential-routes.ts index 2a14d3c5aa..03da8244f5 100644 --- a/src/server/automation-credential-routes.ts +++ b/src/server/automation-credential-routes.ts @@ -1,20 +1,91 @@ import type { Express } from "express"; +import type { + AutomationCredentialCompatibilityInput, + BindAutomationCredentialInput, + CreateAutomationCredentialInput, + PromoteAutomationCredentialInput, + ReplaceAutomationCredentialSecretInput, + RestrictAutomationCredentialInput, + RevokeAutomationCredentialInput, + TestAutomationCredentialInput, + UpdateAutomationCredentialMetadataInput, +} from "../contracts/automation-credential-types.js"; import type { DashboardDependencies } from "./dashboard-server.js"; import { asyncRoute } from "./route-utils.js"; import { requireTrimmedString } from "./request-parsers.js"; -import type { CreateAutomationCredentialInput } from "../contracts/automation-credential-types.js"; - -function broker(deps: DashboardDependencies) { if (!deps.credentialBroker) throw new Error("Credential broker is not enabled."); return deps.credentialBroker; } - -export function registerAutomationCredentialRoutes(app: Express,deps:DashboardDependencies):void{ - app.get("/api/credentials/health",asyncRoute(async(_req,res)=>{res.json(await broker(deps).health());})); - app.get("/api/projects/:projectId/credentials",asyncRoute(async(req,res)=>{res.json(broker(deps).list(requireTrimmedString(req.params.projectId,"projectId")));})); - app.post("/api/projects/:projectId/credentials",asyncRoute(async(req,res)=>{res.status(201).json(await broker(deps).create(requireTrimmedString(req.params.projectId,"projectId"),req.body as CreateAutomationCredentialInput));})); - app.post("/api/projects/:projectId/credentials/:credentialId/bind",asyncRoute(async(req,res)=>{const body=req.body as Record;res.json(broker(deps).bind(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId"),requireTrimmedString(body.bindingKey,"bindingKey"),body.capabilities));})); - app.post("/api/projects/:projectId/credentials/:credentialId/test",asyncRoute(async(req,res)=>{res.json(await broker(deps).test(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId")));})); - app.post("/api/projects/:projectId/credentials/:credentialId/rotate",asyncRoute(async(req,res)=>{res.json(await broker(deps).rotate(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId"),requireTrimmedString((req.body as Record).value,"value")));})); - app.post("/api/projects/:projectId/credentials/:credentialId/replace",asyncRoute(async(req,res)=>{res.json(await broker(deps).replace(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId"),requireTrimmedString((req.body as Record).value,"value")));})); - app.post("/api/projects/:projectId/credentials/:credentialId/revoke",asyncRoute(async(req,res)=>{res.json(broker(deps).revoke(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId")));})); - app.post("/api/projects/:projectId/credentials/:credentialId/promote",asyncRoute(async(req,res)=>{res.json(await broker(deps).promote(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId"),(req.body as Record).allowedProjectIds));})); - app.post("/api/projects/:projectId/credentials/:credentialId/restrict",asyncRoute(async(req,res)=>{const body=req.body as Record;res.json(broker(deps).restrict(requireTrimmedString(req.params.projectId,"projectId"),requireTrimmedString(req.params.credentialId,"credentialId"),body.allowedProjectIds,body.capabilities));})); + +function broker(deps: DashboardDependencies) { + if (!deps.credentialBroker) throw new Error("Credential broker is not enabled."); + return deps.credentialBroker; +} + +function routeIds(params: Record): { projectId: string; credentialId: string } { + return { + projectId: requireTrimmedString(params.projectId, "projectId"), + credentialId: requireTrimmedString(params.credentialId, "credentialId"), + }; +} + +export function registerAutomationCredentialRoutes(app: Express, deps: DashboardDependencies): void { + app.get("/api/credentials/health", asyncRoute(async (_req, res) => { + res.json(await broker(deps).health()); + })); + + app.get("/api/projects/:projectId/credentials", asyncRoute(async (req, res) => { + res.json(broker(deps).list(requireTrimmedString(req.params.projectId, "projectId"))); + })); + + app.post("/api/projects/:projectId/credentials", asyncRoute(async (req, res) => { + const metadata = await broker(deps).create( + requireTrimmedString(req.params.projectId, "projectId"), + req.body as CreateAutomationCredentialInput, + ); + res.status(201).json(metadata); + })); + + app.patch("/api/projects/:projectId/credentials/:credentialId", asyncRoute(async (req, res) => { + const ids = routeIds(req.params); + res.json(broker(deps).updateMetadata(ids.projectId, ids.credentialId, req.body as UpdateAutomationCredentialMetadataInput)); + })); + + app.post("/api/projects/:projectId/credentials/:credentialId/bind", asyncRoute(async (req, res) => { + const ids = routeIds(req.params); + res.json(broker(deps).bind(ids.projectId, ids.credentialId, req.body as BindAutomationCredentialInput)); + })); + + app.post("/api/projects/:projectId/credentials/:credentialId/compatibility", asyncRoute(async (req, res) => { + const ids = routeIds(req.params); + const body = req.body as Omit; + res.json(await broker(deps).assessCompatibility(ids.credentialId, { ...body, projectId: ids.projectId })); + })); + + app.post("/api/projects/:projectId/credentials/:credentialId/test", asyncRoute(async (req, res) => { + const ids = routeIds(req.params); + res.json(await broker(deps).test(ids.projectId, ids.credentialId, req.body as TestAutomationCredentialInput)); + })); + + app.post("/api/projects/:projectId/credentials/:credentialId/rotate", asyncRoute(async (req, res) => { + const ids = routeIds(req.params); + res.json(await broker(deps).rotate(ids.projectId, ids.credentialId, req.body as ReplaceAutomationCredentialSecretInput)); + })); + + app.post("/api/projects/:projectId/credentials/:credentialId/replace", asyncRoute(async (req, res) => { + const ids = routeIds(req.params); + res.json(await broker(deps).replace(ids.projectId, ids.credentialId, req.body as ReplaceAutomationCredentialSecretInput)); + })); + + app.post("/api/projects/:projectId/credentials/:credentialId/revoke", asyncRoute(async (req, res) => { + const ids = routeIds(req.params); + res.json(broker(deps).revoke(ids.projectId, ids.credentialId, req.body as RevokeAutomationCredentialInput)); + })); + + app.post("/api/projects/:projectId/credentials/:credentialId/promote", asyncRoute(async (req, res) => { + const ids = routeIds(req.params); + res.json(await broker(deps).promote(ids.projectId, ids.credentialId, req.body as PromoteAutomationCredentialInput)); + })); + + app.post("/api/projects/:projectId/credentials/:credentialId/restrict", asyncRoute(async (req, res) => { + const ids = routeIds(req.params); + res.json(broker(deps).restrict(ids.projectId, ids.credentialId, req.body as RestrictAutomationCredentialInput)); + })); } diff --git a/src/server/http-errors.ts b/src/server/http-errors.ts index 39f43d625a..c0c967592f 100644 --- a/src/server/http-errors.ts +++ b/src/server/http-errors.ts @@ -36,6 +36,16 @@ export function toHttpRouteError(error: unknown): HttpRouteError { return new HttpRouteError(409, msg); } + if (error && typeof error === "object" && "name" in error && error.name === "CredentialKeyCustodyUnavailableError") { + const msg = "message" in error && typeof error.message === "string" ? error.message : "Credential key custody is unavailable"; + return new HttpRouteError(503, msg); + } + + if (error && typeof error === "object" && "name" in error && error.name === "CredentialEncryptedStateError") { + const msg = "message" in error && typeof error.message === "string" ? error.message : "Credential encrypted state is invalid"; + return new HttpRouteError(422, msg); + } + if (error && typeof error === "object" && "name" in error && error.name === "ProviderRoutingError") { const msg = "message" in (error as any) ? (error as any).message : "Provider routing failed"; return new HttpRouteError(409, msg); diff --git a/src/server/route-utils.ts b/src/server/route-utils.ts index f500059a7c..5e01222d60 100644 --- a/src/server/route-utils.ts +++ b/src/server/route-utils.ts @@ -25,7 +25,7 @@ export function syncRoute(handler: (req: Request, res: Response) => void): Reque } catch (error) { if (!res.headersSent) { const httpError = toHttpRouteError(error); - if (httpError.status >= 500) { + if (httpError.status === 500) { res.status(httpError.status).json({ error: "Internal Server Error" }); next(error); } else { @@ -45,7 +45,7 @@ export function asyncRoute(handler: (req: Request, res: Response) => Promise= 500) { + if (httpError.status === 500) { res.status(httpError.status).json({ error: "Internal Server Error" }); next(error); } else { diff --git a/src/services/credentials/credential-broker.ts b/src/services/credentials/credential-broker.ts index bc04930a40..a6a6de8f78 100644 --- a/src/services/credentials/credential-broker.ts +++ b/src/services/credentials/credential-broker.ts @@ -1,17 +1,30 @@ import { randomUUID } from "node:crypto"; import type { AutomationCredentialBinding, + AutomationCredentialCompatibilityAssessment, + AutomationCredentialCompatibilityInput, + AutomationCredentialCompatibilityIssue, AutomationCredentialMetadata, - AutomationCredentialStatus, + BindAutomationCredentialInput, CreateAutomationCredentialInput, CredentialBackendHealth, CredentialResolutionRequest, + PromoteAutomationCredentialInput, + ReplaceAutomationCredentialSecretInput, ResolvedCredential, + RestrictAutomationCredentialInput, + RevokeAutomationCredentialInput, + TestAutomationCredentialInput, + UpdateAutomationCredentialMetadataInput, } from "../../contracts/automation-credential-types.js"; -import type { AutomationCredentialRepository } from "../../repositories/automation-credential-repository.js"; +import { + CredentialConcurrentModificationError, + type AutomationCredentialRepository, +} from "../../repositories/automation-credential-repository.js"; import { ValidationError } from "../../repositories/repository-utils.js"; import type { AutomationAuditExportService } from "../automation-audit-export-service.js"; import type { KeyProvider } from "./key-provider.js"; +import { KeyProviderUnavailableError } from "./key-provider.js"; import type { SecretContext, SecretStore } from "./secret-store.js"; const MAX_NAME_LENGTH = 128; @@ -31,6 +44,20 @@ export class CredentialAccessDeniedError extends Error { } } +export class CredentialKeyCustodyUnavailableError extends Error { + constructor(message = "Credential key custody is unavailable; restore the configured secure key provider and retry.") { + super(message); + this.name = "CredentialKeyCustodyUnavailableError"; + } +} + +export class CredentialEncryptedStateError extends Error { + constructor(message = "The credential's encrypted state is invalid or unavailable; replace its value with the current version before retrying.") { + super(message); + this.name = "CredentialEncryptedStateError"; + } +} + function boundedString(value: unknown, label: string, maxLength: number): string { if (typeof value !== "string") throw new ValidationError(`${label} must be a string.`); const normalized = value.trim(); @@ -41,19 +68,35 @@ function boundedString(value: unknown, label: string, maxLength: number): string } function boundedList(value: unknown, label: string, itemMaxLength: number): string[] { - if (value === undefined) return []; - if (!Array.isArray(value)) throw new ValidationError(`${label} must be an array of strings.`); + if (!Array.isArray(value)) throw new ValidationError(`${label} must be an explicit array of strings.`); if (value.length > MAX_LIST_ITEMS) throw new ValidationError(`${label} cannot contain more than ${MAX_LIST_ITEMS} entries.`); const normalized = value.map((item) => boundedString(item, `${label} entry`, itemMaxLength)); return [...new Set(normalized)]; } +function expectedVersion(value: unknown): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) { + throw new ValidationError("expectedVersion must be a positive safe integer."); + } + return value; +} + function secretValue(value: unknown, label = "value"): string { if (typeof value !== "string" || value.length === 0) throw new ValidationError(`A non-empty ${label} is required.`); if (Buffer.byteLength(value, "utf8") > MAX_SECRET_BYTES) throw new ValidationError(`${label} must be at most ${MAX_SECRET_BYTES} UTF-8 bytes.`); return value; } +function requireObject(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${label} must be an object.`); + return value as Record; +} + +function rejectUnknownFields(input: Record, allowed: readonly string[], label: string): void { + const unknown = Object.keys(input).filter((key) => !allowed.includes(key)); + if (unknown.length > 0) throw new ValidationError(`${label} contains unsupported fields: ${unknown.sort().join(", ")}.`); +} + function sameCredentialSnapshot(left: AutomationCredentialMetadata, right: AutomationCredentialMetadata): boolean { return left.id === right.id && left.version === right.version @@ -83,136 +126,289 @@ export class CredentialBroker { return this.repository.list(boundedString(projectId, "projectId", MAX_IDENTIFIER_LENGTH)); } - async create(projectIdValue: string, input: CreateAutomationCredentialInput): Promise { + async create(projectIdValue: string, inputValue: CreateAutomationCredentialInput): Promise { const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH); - this.repository.requireProject(projectId); - const name = boundedString(input?.name, "name", MAX_NAME_LENGTH); - const kind = boundedString(input?.kind, "kind", MAX_KIND_LENGTH); - if (!CREDENTIAL_KIND.test(kind)) throw new ValidationError("kind may contain only letters, numbers, dots, underscores, colons, and hyphens."); - const value = secretValue(input?.value); - const scope = input?.scope ?? "project"; - if (scope !== "project" && scope !== "global") throw new ValidationError("scope must be either project or global."); - const requestedProjects = boundedList(input?.allowedProjectIds, "allowedProjectIds", MAX_IDENTIFIER_LENGTH); - if (scope === "global" && !requestedProjects.includes(projectId)) { - throw new ValidationError("Global credentials require an explicit allowlist containing the configuring project."); + let credentialId: string | null = null; + try { + this.repository.requireProject(projectId); + const input = requireObject(inputValue, "credential") as unknown as CreateAutomationCredentialInput & Record; + rejectUnknownFields(input, ["name", "kind", "value", "scope", "allowedProjectIds", "capabilities"], "credential"); + const name = boundedString(input.name, "name", MAX_NAME_LENGTH); + const kind = boundedString(input.kind, "kind", MAX_KIND_LENGTH); + if (!CREDENTIAL_KIND.test(kind)) throw new ValidationError("kind may contain only letters, numbers, dots, underscores, colons, and hyphens."); + const value = secretValue(input.value); + const scope = input.scope; + if (scope !== "project" && scope !== "global") throw new ValidationError("scope must be explicitly set to project or global."); + const requestedProjects = boundedList(input.allowedProjectIds, "allowedProjectIds", MAX_IDENTIFIER_LENGTH); + if (scope === "project" && requestedProjects.length > 0) { + throw new ValidationError("Project credentials must use an empty allowedProjectIds array."); + } + if (scope === "global" && !requestedProjects.includes(projectId)) { + throw new ValidationError("Global credentials require an explicit allowlist containing the configuring project."); + } + const allowedProjectIds = scope === "global" + ? [projectId, ...requestedProjects.filter((candidate) => candidate !== projectId)] + : []; + for (const allowedProjectId of allowedProjectIds) this.repository.requireProject(allowedProjectId); + const capabilities = boundedList(input.capabilities, "capabilities", MAX_CAPABILITY_LENGTH); + await this.requireBackendReady(); + credentialId = randomUUID(); + const plaintext = Buffer.from(value, "utf8"); + try { + const envelope = await this.secretStore.seal(this.contextFor(credentialId, scope === "project" ? projectId : null), plaintext); + const created = this.repository.createWithEnvelope({ + id: credentialId, + name, + kind, + scope, + projectId: scope === "project" ? projectId : null, + managementProjectId: projectId, + allowedProjectIds, + capabilities, + }, envelope); + this.auditLifecycle("credential.create", projectId, created, "succeeded"); + return created; + } finally { + plaintext.fill(0); + } + } catch (error) { + this.auditLifecycleFailure("credential.create", projectId, credentialId, error); + throw this.normalizeCustodyError(error); } - const allowedProjectIds = scope === "global" - ? [projectId, ...requestedProjects.filter((candidate) => candidate !== projectId)] - : []; - const capabilities = boundedList(input?.capabilities, "capabilities", MAX_CAPABILITY_LENGTH); - const id = randomUUID(); - const context = this.contextFor(id, scope === "project" ? projectId : null); - const plaintext = Buffer.from(value, "utf8"); + } + + updateMetadata(projectIdValue: string, credentialIdValue: string, inputValue: UpdateAutomationCredentialMetadataInput): AutomationCredentialMetadata { + const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH); + const credentialId = boundedString(credentialIdValue, "credentialId", MAX_IDENTIFIER_LENGTH); try { - const envelope = await this.secretStore.seal(context, plaintext); - return this.repository.createWithEnvelope({ - id, - name, - kind, - scope, - projectId: scope === "project" ? projectId : null, - managementProjectId: projectId, - allowedProjectIds, - capabilities, - }, envelope); - } finally { - plaintext.fill(0); + const input = requireObject(inputValue, "metadata update"); + rejectUnknownFields(input, ["name", "expectedVersion"], "metadata update"); + const credential = this.requireManageable(projectId, credentialId); + this.requireExpectedVersion(credential, expectedVersion(input.expectedVersion)); + const updated = this.repository.updateMetadata({ + credentialId, + expectedVersion: credential.version, + name: boundedString(input.name, "name", MAX_NAME_LENGTH), + }); + this.auditLifecycle("credential.update", projectId, updated, "succeeded"); + return updated; + } catch (error) { + this.auditLifecycleFailure("credential.update", projectId, credentialId, error); + throw error; } } - bind(projectIdValue: string, credentialIdValue: string, bindingKeyValue: string, capabilitiesValue: unknown): AutomationCredentialBinding { + bind(projectIdValue: string, credentialIdValue: string, inputValue: BindAutomationCredentialInput): AutomationCredentialBinding { const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH); const credentialId = boundedString(credentialIdValue, "credentialId", MAX_IDENTIFIER_LENGTH); - const bindingKey = boundedString(bindingKeyValue, "bindingKey", MAX_IDENTIFIER_LENGTH); - const credential = this.requireAccessible(projectId, credentialId); - if (credential.status !== "active") throw new CredentialAccessDeniedError("Only active credentials can be bound."); - const required = boundedList(capabilitiesValue, "capabilities", MAX_CAPABILITY_LENGTH); - if (required.some((capability) => !credential.capabilities.includes(capability))) { - throw new CredentialAccessDeniedError("Binding requests capabilities the credential does not grant."); + try { + const input = requireObject(inputValue, "binding"); + rejectUnknownFields(input, ["bindingKey", "requiredCapabilities"], "binding"); + const bindingKey = boundedString(input.bindingKey, "bindingKey", MAX_IDENTIFIER_LENGTH); + const credential = this.requireAccessible(projectId, credentialId); + if (credential.status !== "active") throw new CredentialAccessDeniedError("Only active credentials can be bound."); + const required = boundedList(input.requiredCapabilities, "requiredCapabilities", MAX_CAPABILITY_LENGTH); + if (required.some((capability) => !credential.capabilities.includes(capability))) { + throw new CredentialAccessDeniedError("Binding requests capabilities the credential does not grant."); + } + const binding = this.repository.bind(credentialId, projectId, bindingKey, required); + this.auditLifecycle("credential.bind", projectId, credential, "succeeded"); + return binding; + } catch (error) { + this.auditLifecycleFailure("credential.bind", projectId, credentialId, error); + throw error; } - return this.repository.bind(credentialId, projectId, bindingKey, required); } - async test(projectIdValue: string, credentialIdValue: string): Promise { + async assessCompatibility(credentialIdValue: string, inputValue: AutomationCredentialCompatibilityInput): Promise { + const credentialId = boundedString(credentialIdValue, "credentialId", MAX_IDENTIFIER_LENGTH); + const input = requireObject(inputValue, "compatibility assessment"); + rejectUnknownFields(input, ["projectId", "allowedKinds", "requiredCapabilities"], "compatibility assessment"); + const projectId = boundedString(input.projectId, "projectId", MAX_IDENTIFIER_LENGTH); + this.repository.requireProject(projectId); + const allowedKinds = boundedList(input.allowedKinds, "allowedKinds", MAX_KIND_LENGTH); + if (allowedKinds.length === 0) throw new ValidationError("allowedKinds must declare at least one permitted credential kind."); + const requiredCapabilities = boundedList(input.requiredCapabilities, "requiredCapabilities", MAX_CAPABILITY_LENGTH); + const health = await this.safeHealth(); + const credential = this.repository.get(credentialId); + const projectAccess = credential !== null && this.canAccess(credential, projectId); + const configured = projectAccess && credential.configured; + const active = projectAccess && credential.status === "active"; + const kindAllowed = projectAccess && allowedKinds.includes(credential.kind); + const missingCapabilities = projectAccess + ? requiredCapabilities.filter((capability) => !credential.capabilities.includes(capability)) + : [...requiredCapabilities]; + const capabilitiesAllowed = projectAccess && missingCapabilities.length === 0; + const issues: AutomationCredentialCompatibilityIssue[] = []; + if (!health.available) issues.push("backend_unavailable"); + else if (!health.secure) issues.push("backend_insecure"); + if (!configured) issues.push("not_configured"); + if (!active) issues.push("not_active"); + if (!projectAccess) issues.push("project_access_denied"); + if (!kindAllowed) issues.push("kind_not_allowed"); + if (!capabilitiesAllowed) issues.push("capability_missing"); + return { + credentialId, + projectId, + compatible: issues.length === 0, + backendReady: health.available && health.secure, + configured, + active, + projectAccess, + kindAllowed, + capabilitiesAllowed, + missingCapabilities, + issues, + metadata: projectAccess ? credential : null, + }; + } + + async test(projectIdValue: string, credentialIdValue: string, inputValue: TestAutomationCredentialInput): Promise { const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH); const credentialId = boundedString(credentialIdValue, "credentialId", MAX_IDENTIFIER_LENGTH); - const credential = this.requireAccessible(projectId, credentialId); - let plaintext: Buffer | null = null; try { - plaintext = await this.secretStore.get(this.context(credential)); - const current = this.repository.get(credentialId); - if (!current || !sameCredentialSnapshot(credential, current) || !this.canAccess(current, projectId) || current.status !== "active") { - throw new CredentialAccessDeniedError("Credential changed while it was being tested; retry the operation."); + const input = requireObject(inputValue, "test request"); + rejectUnknownFields(input, ["expectedVersion"], "test request"); + const credential = this.requireManageable(projectId, credentialId); + this.requireExpectedVersion(credential, expectedVersion(input.expectedVersion)); + if (credential.status !== "active") throw new CredentialAccessDeniedError("Only active credentials can be tested."); + let plaintext: Buffer | null = null; + try { + plaintext = await this.secretStore.get(this.context(credential)); + const current = this.repository.get(credentialId); + if (!current || !sameCredentialSnapshot(credential, current) || !this.canAccess(current, projectId) || current.status !== "active") { + throw new CredentialAccessDeniedError("Credential changed while it was being tested; refresh its metadata and retry."); + } + const updated = this.repository.updateValidation({ credentialId, expectedVersion: credential.version, status: "valid" }); + this.auditLifecycle("credential.test", projectId, updated, "succeeded"); + return updated; + } catch (error) { + if (error instanceof CredentialAccessDeniedError || (error instanceof Error && error.name === "CredentialConcurrentModificationError")) throw error; + const normalized = await this.classifyEncryptedStateError(error); + const status = normalized instanceof CredentialKeyCustodyUnavailableError ? "unavailable" : "invalid"; + this.repository.updateValidation({ credentialId, expectedVersion: credential.version, status }); + throw normalized; + } finally { + plaintext?.fill(0); } - return this.repository.updateValidation(credentialId, "valid"); } catch (error) { - if (error instanceof CredentialAccessDeniedError) throw error; - this.repository.updateValidation(credentialId, "invalid"); - throw new Error("Credential validation failed."); - } finally { - plaintext?.fill(0); + this.auditLifecycleFailure("credential.test", projectId, credentialId, error); + throw error; } } - async rotate(projectId: string, credentialId: string, value: string): Promise { - return this.replaceValue(projectId, credentialId, value, true); + async rotate(projectId: string, credentialId: string, input: ReplaceAutomationCredentialSecretInput): Promise { + return this.replaceValue(projectId, credentialId, input, true); } - async replace(projectId: string, credentialId: string, value: string): Promise { - return this.replaceValue(projectId, credentialId, value, false); + async replace(projectId: string, credentialId: string, input: ReplaceAutomationCredentialSecretInput): Promise { + return this.replaceValue(projectId, credentialId, input, false); } - revoke(projectIdValue: string, credentialIdValue: string): AutomationCredentialMetadata { + revoke(projectIdValue: string, credentialIdValue: string, inputValue: RevokeAutomationCredentialInput): AutomationCredentialMetadata { const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH); const credentialId = boundedString(credentialIdValue, "credentialId", MAX_IDENTIFIER_LENGTH); - this.requireManageable(projectId, credentialId); - return this.repository.updateStatus(credentialId, "revoked"); + try { + const input = requireObject(inputValue, "revoke request"); + rejectUnknownFields(input, ["expectedVersion"], "revoke request"); + const credential = this.requireManageable(projectId, credentialId); + const version = expectedVersion(input.expectedVersion); + this.requireExpectedVersion(credential, version); + const revoked = this.repository.revoke({ credentialId, expectedVersion: version }); + this.auditLifecycle("credential.revoke", projectId, revoked, "succeeded"); + return revoked; + } catch (error) { + this.auditLifecycleFailure("credential.revoke", projectId, credentialId, error); + throw error; + } } - async promote(projectIdValue: string, credentialIdValue: string, allowedProjectIdsValue: unknown): Promise { + async promote(projectIdValue: string, credentialIdValue: string, inputValue: PromoteAutomationCredentialInput): Promise { const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH); const credentialId = boundedString(credentialIdValue, "credentialId", MAX_IDENTIFIER_LENGTH); - const credential = this.requireManageable(projectId, credentialId); - if (credential.scope !== "project" || credential.projectId !== projectId) { - throw new CredentialAccessDeniedError("Only the managing project can promote a project credential."); - } - if (credential.status !== "active") throw new CredentialAccessDeniedError("Only active credentials can be promoted."); - const requestedProjects = boundedList(allowedProjectIdsValue, "allowedProjectIds", MAX_IDENTIFIER_LENGTH); - if (!requestedProjects.includes(projectId)) throw new ValidationError("The global allowlist must retain the managing project."); - const allowedProjectIds = [projectId, ...requestedProjects.filter((candidate) => candidate !== projectId)]; - for (const allowedProjectId of allowedProjectIds) this.repository.requireProject(allowedProjectId); - const plaintext = await this.secretStore.get(this.context(credential)); try { - const envelope = await this.secretStore.seal(this.contextFor(credential.id, null), plaintext); - return this.repository.promoteWithEnvelope({ - credentialId, - managementProjectId: projectId, - expectedVersion: credential.version, - expectedStatus: credential.status, - allowedProjectIds, - envelope, - }); - } finally { - plaintext.fill(0); + const input = requireObject(inputValue, "promotion request"); + rejectUnknownFields(input, ["allowedProjectIds", "expectedVersion", "confirmScopeExpansion"], "promotion request"); + if (input.confirmScopeExpansion !== true) { + throw new ValidationError("confirmScopeExpansion must be true to promote a project credential to global scope."); + } + const credential = this.requireManageable(projectId, credentialId); + this.requireExpectedVersion(credential, expectedVersion(input.expectedVersion)); + if (credential.scope !== "project" || credential.projectId !== projectId) { + throw new CredentialAccessDeniedError("Only the managing project can promote its project credential."); + } + if (credential.status !== "active") throw new CredentialAccessDeniedError("Only active credentials can be promoted."); + const requestedProjects = boundedList(input.allowedProjectIds, "allowedProjectIds", MAX_IDENTIFIER_LENGTH); + if (!requestedProjects.includes(projectId)) throw new ValidationError("The global allowlist must retain the managing project."); + const allowedProjectIds = [projectId, ...requestedProjects.filter((candidate) => candidate !== projectId)]; + for (const allowedProjectId of allowedProjectIds) this.repository.requireProject(allowedProjectId); + await this.requireBackendReady(); + let plaintext: Buffer | null = null; + try { + plaintext = await this.secretStore.get(this.context(credential)); + const envelope = await this.secretStore.seal(this.contextFor(credential.id, null), plaintext); + const promoted = this.repository.promoteWithEnvelope({ + credentialId, + managementProjectId: projectId, + expectedVersion: credential.version, + expectedStatus: credential.status, + allowedProjectIds, + envelope, + }); + this.auditLifecycle("credential.promote", projectId, promoted, "succeeded"); + return promoted; + } catch (error) { + if (error instanceof CredentialAccessDeniedError || (error instanceof Error && error.name === "CredentialConcurrentModificationError")) throw error; + throw await this.classifyEncryptedStateError(error); + } finally { + plaintext?.fill(0); + } + } catch (error) { + this.auditLifecycleFailure("credential.promote", projectId, credentialId, error); + throw this.normalizeCustodyError(error); } } - restrict(projectIdValue: string, credentialIdValue: string, allowedProjectIdsValue: unknown, capabilitiesValue: unknown): AutomationCredentialMetadata { + restrict(projectIdValue: string, credentialIdValue: string, inputValue: RestrictAutomationCredentialInput): AutomationCredentialMetadata { const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH); const credentialId = boundedString(credentialIdValue, "credentialId", MAX_IDENTIFIER_LENGTH); - const credential = this.requireManageable(projectId, credentialId); - const requestedProjects = boundedList(allowedProjectIdsValue, "allowedProjectIds", MAX_IDENTIFIER_LENGTH); - if (credential.scope === "global" && !requestedProjects.includes(projectId)) { - throw new ValidationError("The global allowlist must retain the managing project."); + try { + const input = requireObject(inputValue, "restriction request"); + rejectUnknownFields(input, ["allowedProjectIds", "capabilities", "expectedVersion"], "restriction request"); + const credential = this.requireManageable(projectId, credentialId); + this.requireExpectedVersion(credential, expectedVersion(input.expectedVersion)); + const requestedProjects = boundedList(input.allowedProjectIds, "allowedProjectIds", MAX_IDENTIFIER_LENGTH); + const capabilities = boundedList(input.capabilities, "capabilities", MAX_CAPABILITY_LENGTH); + if (credential.scope === "project" && requestedProjects.length > 0) { + throw new ValidationError("Project credential restrictions must use an empty allowedProjectIds array."); + } + if (credential.scope === "global" && !requestedProjects.includes(projectId)) { + throw new ValidationError("The global allowlist must retain the managing project."); + } + const allowedProjectIds = credential.scope === "global" + ? [projectId, ...requestedProjects.filter((candidate) => candidate !== projectId)] + : []; + const expandedProject = allowedProjectIds.find((candidate) => !credential.allowedProjectIds.includes(candidate)); + if (expandedProject) throw new ValidationError("Restriction cannot add project access; use promotion only for the project-to-global scope expansion."); + const expandedCapability = capabilities.find((candidate) => !credential.capabilities.includes(candidate)); + if (expandedCapability) throw new ValidationError("Restriction cannot add capabilities; submit only capabilities already granted by the credential."); + for (const allowedProjectId of allowedProjectIds) this.repository.requireProject(allowedProjectId); + const restricted = this.repository.restrict({ + credentialId, + expectedVersion: credential.version, + allowedProjectIds, + capabilities, + }); + this.auditLifecycle("credential.restrict", projectId, restricted, "succeeded"); + return restricted; + } catch (error) { + this.auditLifecycleFailure("credential.restrict", projectId, credentialId, error); + throw error; } - const allowedProjectIds = credential.scope === "global" - ? [projectId, ...requestedProjects.filter((candidate) => candidate !== projectId)] - : []; - const capabilities = boundedList(capabilitiesValue, "capabilities", MAX_CAPABILITY_LENGTH); - return this.repository.restrict(credentialId, allowedProjectIds, capabilities); } - async resolve(request: CredentialResolutionRequest): Promise { + async resolve(requestValue: CredentialResolutionRequest): Promise { + const request = this.normalizeResolutionRequest(requestValue); for (let attempt = 0; attempt < MAX_RESOLUTION_RETRIES; attempt += 1) { const binding = this.repository.getBinding(request.projectId, request.bindingKey); if (!binding) return this.deny(request, null, "No credential binding exists."); @@ -222,7 +418,7 @@ export class CredentialBroker { const currentBinding = this.repository.getBinding(request.projectId, request.bindingKey); const currentCredential = currentBinding ? this.repository.get(currentBinding.credentialId) : null; const stable = currentBinding?.credentialId === binding.credentialId - && currentBinding.requiredCapabilities.includes(request.capability) + && request.requiredCapabilities.every((capability) => currentBinding.requiredCapabilities.includes(capability)) && currentCredential !== null && sameCredentialSnapshot(credential!, currentCredential); if (stable) return this.grant(request, currentCredential!, plaintext); @@ -232,40 +428,73 @@ export class CredentialBroker { return this.deny(request, null, "Credential changed repeatedly while access was being authorized."); } - async resolveCredentialId(request: CredentialResolutionRequest & { credentialId: string }): Promise { + async resolveCredentialId(requestValue: CredentialResolutionRequest & { credentialId: string }): Promise { + const credentialId = boundedString(requestValue.credentialId, "credentialId", MAX_IDENTIFIER_LENGTH); + const request = { ...this.normalizeResolutionRequest(requestValue), credentialId }; for (let attempt = 0; attempt < MAX_RESOLUTION_RETRIES; attempt += 1) { - const credential = this.repository.get(request.credentialId); + const credential = this.repository.get(credentialId); this.authorizeDirectResolution(request, credential); const plaintext = await this.readSecretOrDeny(request, credential!); - const current = this.repository.get(request.credentialId); + const current = this.repository.get(credentialId); if (current && sameCredentialSnapshot(credential!, current)) return this.grant(request, current, plaintext); plaintext.fill(0); this.authorizeDirectResolution(request, current); } - return this.deny(request, request.credentialId, "Credential changed repeatedly while access was being authorized."); + return this.deny(request, credentialId, "Credential changed repeatedly while access was being authorized."); } - private async replaceValue(projectIdValue: string, credentialIdValue: string, valueValue: string, rotation: boolean): Promise { + private async replaceValue( + projectIdValue: string, + credentialIdValue: string, + inputValue: ReplaceAutomationCredentialSecretInput, + rotation: boolean, + ): Promise { const projectId = boundedString(projectIdValue, "projectId", MAX_IDENTIFIER_LENGTH); const credentialId = boundedString(credentialIdValue, "credentialId", MAX_IDENTIFIER_LENGTH); - const credential = this.requireManageable(projectId, credentialId); - if (rotation && credential.status !== "active") throw new CredentialAccessDeniedError("Only active credentials can be rotated."); - const value = secretValue(valueValue, "replacement value"); - const plaintext = Buffer.from(value, "utf8"); + const action = rotation ? "credential.rotate" : "credential.replace"; try { - const envelope = await this.secretStore.seal(this.context(credential), plaintext); - return this.repository.replaceEnvelope({ - credentialId, - expectedVersion: credential.version, - expectedStatus: credential.status, - envelope, - recordRotation: rotation, - }); - } finally { - plaintext.fill(0); + const input = requireObject(inputValue, rotation ? "rotation request" : "replacement request"); + rejectUnknownFields(input, ["value", "expectedVersion"], rotation ? "rotation request" : "replacement request"); + const credential = this.requireManageable(projectId, credentialId); + this.requireExpectedVersion(credential, expectedVersion(input.expectedVersion)); + if (rotation && credential.status !== "active") throw new CredentialAccessDeniedError("Only active credentials can be rotated."); + if (!rotation && credential.status === "revoked") throw new CredentialAccessDeniedError("Revoked credentials cannot be reactivated; create a new credential instead."); + const value = secretValue(input.value, rotation ? "rotation value" : "replacement value"); + await this.requireBackendReady(); + const plaintext = Buffer.from(value, "utf8"); + try { + const envelope = await this.secretStore.seal(this.context(credential), plaintext); + const updated = this.repository.replaceEnvelope({ + credentialId, + expectedVersion: credential.version, + expectedStatus: credential.status, + envelope, + recordRotation: rotation, + }); + this.auditLifecycle(action, projectId, updated, "succeeded"); + return updated; + } finally { + plaintext.fill(0); + } + } catch (error) { + this.auditLifecycleFailure(action, projectId, credentialId, error); + throw this.normalizeCustodyError(error); } } + private normalizeResolutionRequest(requestValue: CredentialResolutionRequest): CredentialResolutionRequest { + const input = requireObject(requestValue, "credential resolution request"); + const allowedKinds = boundedList(input.allowedKinds, "allowedKinds", MAX_KIND_LENGTH); + if (allowedKinds.length === 0) throw new ValidationError("allowedKinds must declare at least one permitted credential kind."); + return { + projectId: boundedString(input.projectId, "projectId", MAX_IDENTIFIER_LENGTH), + bindingKey: boundedString(input.bindingKey, "bindingKey", MAX_IDENTIFIER_LENGTH), + workspaceId: boundedString(input.workspaceId, "workspaceId", MAX_IDENTIFIER_LENGTH), + allowedKinds, + requiredCapabilities: boundedList(input.requiredCapabilities, "requiredCapabilities", MAX_CAPABILITY_LENGTH), + }; + } + private context(credential: AutomationCredentialMetadata): SecretContext { return this.contextFor(credential.id, credential.projectId); } @@ -299,28 +528,47 @@ export class CredentialBroker { return credential; } - private authorizeBoundResolution(request: CredentialResolutionRequest, binding: AutomationCredentialBinding | null, credential: AutomationCredentialMetadata | null): void { + private requireExpectedVersion(credential: AutomationCredentialMetadata, version: number): void { + if (credential.version !== version) { + throw new CredentialConcurrentModificationError("Credential changed; refresh its metadata and retry with the current version."); + } + } + + private authorizeBoundResolution( + request: CredentialResolutionRequest, + binding: AutomationCredentialBinding | null, + credential: AutomationCredentialMetadata | null, + ): void { if (!binding) return this.deny(request, null, "No credential binding exists."); if (!credential) return this.deny(request, binding.credentialId, "Bound credential is missing."); - if (!this.canAccess(credential, request.projectId)) return this.deny(request, credential.id, "Credential is outside the project scope."); - if (credential.status !== "active") return this.deny(request, credential.id, "Credential is not active."); - if (!credential.capabilities.includes(request.capability) || !binding.requiredCapabilities.includes(request.capability)) { - return this.deny(request, credential.id, "Required capability is not approved."); + this.authorizeCredentialPolicy(request, credential); + if (!request.requiredCapabilities.every((capability) => binding.requiredCapabilities.includes(capability))) { + return this.deny(request, credential.id, "The binding does not approve every required capability."); } } - private authorizeDirectResolution(request: CredentialResolutionRequest & { credentialId: string }, credential: AutomationCredentialMetadata | null): void { + private authorizeDirectResolution( + request: CredentialResolutionRequest & { credentialId: string }, + credential: AutomationCredentialMetadata | null, + ): void { if (!credential) return this.deny(request, request.credentialId, "Credential is missing."); + this.authorizeCredentialPolicy(request, credential); + } + + private authorizeCredentialPolicy(request: CredentialResolutionRequest, credential: AutomationCredentialMetadata): void { if (!this.canAccess(credential, request.projectId)) return this.deny(request, credential.id, "Credential is outside the project scope."); if (credential.status !== "active") return this.deny(request, credential.id, "Credential is not active."); - if (!credential.capabilities.includes(request.capability)) return this.deny(request, credential.id, "Required capability is not approved."); + if (!request.allowedKinds.includes(credential.kind)) return this.deny(request, credential.id, "Credential kind is not approved for this consumer."); + if (!request.requiredCapabilities.every((capability) => credential.capabilities.includes(capability))) { + return this.deny(request, credential.id, "Credential does not approve every required capability."); + } } private async readSecretOrDeny(request: CredentialResolutionRequest, credential: AutomationCredentialMetadata): Promise { try { return await this.secretStore.get(this.context(credential)); } catch { - return this.deny(request, credential.id, "Credential backend is unavailable or authentication failed."); + return this.deny(request, credential.id, "Credential encrypted state or key custody is unavailable."); } } @@ -330,7 +578,7 @@ export class CredentialBroker { credentialId: credential.id, projectId: request.projectId, bindingKey: request.bindingKey, - capability: request.capability, + capability: request.requiredCapabilities.join(",") || null, operation: "resolve", outcome: "granted", reason: null, @@ -341,7 +589,12 @@ export class CredentialBroker { resourceId: credential.id, projectId: request.projectId, outcome: "succeeded", - metadata: { bindingKey: request.bindingKey, capability: request.capability, credentialVersion: credential.version }, + metadata: { + bindingKey: request.bindingKey, + requiredCapabilities: request.requiredCapabilities, + allowedKinds: request.allowedKinds, + credentialVersion: credential.version, + }, }); return { credentialId: credential.id, value: plaintext.toString("utf8"), version: credential.version }; } finally { @@ -354,7 +607,7 @@ export class CredentialBroker { credentialId, projectId: request.projectId, bindingKey: request.bindingKey, - capability: request.capability, + capability: request.requiredCapabilities.join(",") || null, operation: "resolve", outcome: "denied", reason, @@ -365,8 +618,104 @@ export class CredentialBroker { resourceId: credentialId, projectId: request.projectId, outcome: "denied", - metadata: { bindingKey: request.bindingKey, capability: request.capability, reason }, + metadata: { + bindingKey: request.bindingKey, + requiredCapabilities: request.requiredCapabilities, + allowedKinds: request.allowedKinds, + reason, + }, }); throw new CredentialAccessDeniedError(reason); } + + private async requireBackendReady(): Promise { + const health = await this.safeHealth(); + if (!health.available || !health.secure || !health.keyId || health.keyVersion === null) { + throw new CredentialKeyCustodyUnavailableError(); + } + } + + private async safeHealth(): Promise { + try { + return await this.keyProvider.health(); + } catch { + return { + available: false, + secure: false, + provider: this.keyProvider.providerName, + keyId: null, + keyVersion: null, + reason: "Credential key provider health check failed.", + }; + } + } + + private async classifyEncryptedStateError(error: unknown): Promise { + if (error instanceof CredentialKeyCustodyUnavailableError || error instanceof KeyProviderUnavailableError) { + return new CredentialKeyCustodyUnavailableError(); + } + const health = await this.safeHealth(); + return health.available && health.secure + ? new CredentialEncryptedStateError() + : new CredentialKeyCustodyUnavailableError(); + } + + private normalizeCustodyError(error: unknown): unknown { + return error instanceof KeyProviderUnavailableError ? new CredentialKeyCustodyUnavailableError() : error; + } + + private auditLifecycle( + action: string, + projectId: string, + credential: AutomationCredentialMetadata, + outcome: "succeeded" | "denied" | "failed", + ): void { + try { + this.auditService?.recordSystem({ + action, + resourceType: "automation_credential", + resourceId: credential.id, + projectId, + outcome, + metadata: { + credentialVersion: credential.version, + kind: credential.kind, + scope: credential.scope, + managementProjectId: credential.managementProjectId, + allowedProjectIds: credential.allowedProjectIds, + capabilities: credential.capabilities, + status: credential.status, + configured: credential.configured, + validationStatus: credential.validationStatus, + }, + }); + } catch { + // Credential availability must not depend on the audit exporter being writable. + } + } + + private auditLifecycleFailure(action: string, projectId: string, credentialId: string | null, error: unknown): void { + try { + const credential = credentialId ? this.repository.get(credentialId) : null; + this.auditService?.recordSystem({ + action, + resourceType: "automation_credential", + resourceId: credentialId, + projectId, + outcome: error instanceof CredentialAccessDeniedError ? "denied" : "failed", + metadata: { + errorType: error instanceof Error ? error.name : "UnknownError", + credentialVersion: credential?.version ?? null, + kind: credential?.kind ?? null, + scope: credential?.scope ?? null, + managementProjectId: credential?.managementProjectId ?? null, + allowedProjectIds: credential?.allowedProjectIds ?? [], + capabilities: credential?.capabilities ?? [], + status: credential?.status ?? null, + }, + }); + } catch { + // Preserve the original lifecycle error when audit persistence is unavailable. + } + } } diff --git a/src/services/custom-nodes/custom-node-runtime-service.ts b/src/services/custom-nodes/custom-node-runtime-service.ts index 4e2ff2c351..6170e8dc6f 100644 --- a/src/services/custom-nodes/custom-node-runtime-service.ts +++ b/src/services/custom-nodes/custom-node-runtime-service.ts @@ -155,7 +155,8 @@ export class CustomNodeRuntimeService { projectId: request.projectId, bindingKey, credentialId: bindingKey, - capability: slot.requiredCapability, + requiredCapabilities: [slot.requiredCapability], + allowedKinds: slot.allowedKinds, workspaceId: request.workspaceId, }); resolved[slot.slot] = credential.value; diff --git a/src/services/node-flow-runtime-service.ts b/src/services/node-flow-runtime-service.ts index 2ede546adc..08de57729a 100644 --- a/src/services/node-flow-runtime-service.ts +++ b/src/services/node-flow-runtime-service.ts @@ -1047,7 +1047,9 @@ export class NodeFlowRuntimeService { const binding=node.credentialBindings?.find((candidate)=>candidate.slot===slot); if (!binding) return undefined; if (!this.deps.credentialBroker) throw new ValidationError("Credential broker is not configured for node flow runtime."); - const resolved=await this.deps.credentialBroker.resolveCredentialId({projectId:context.projectId,credentialId:binding.credentialId,bindingKey:`${context.flowId}:${node.id}:${slot}`,capability:"read",workspaceId:context.runId}); + const requirement=node.definition?resolveNodeDefinition(node.definition.type,node.definition.version)?.credentials.find((candidate)=>candidate.slot===slot):undefined; + if (!requirement) throw new ValidationError(`Node ${node.id} does not declare credential slot ${slot}.`); + const resolved=await this.deps.credentialBroker.resolveCredentialId({projectId:context.projectId,credentialId:binding.credentialId,bindingKey:`${context.flowId}:${node.id}:${slot}`,requiredCapabilities:["read"],allowedKinds:requirement.allowedKinds,workspaceId:context.runId}); if (resolved.value) context.resolvedCredentialValues.push(resolved.value); return resolved.value; } diff --git a/src/shared/security/redaction.ts b/src/shared/security/redaction.ts index 5df3f1c4bc..7fbf65ae36 100644 --- a/src/shared/security/redaction.ts +++ b/src/shared/security/redaction.ts @@ -1,5 +1,6 @@ const SENSITIVE_KEYS_LIST = [ "apiKey", "token", "authorization", "password", "secret", + "credentialValue", "replacementValue", "rotationValue", "anthropicApiKey", "codexApiKey", "geminiApiKey", "jiraApiToken", "julesApiKey", "openaiCompatibleApiKey", "openRouterApiKey", "providerApiKey", "qwenApiKey", "githubToken", "gitlabToken", "jiraToken", diff --git a/tests/backend/repositories/automation-credential-repository.test.ts b/tests/backend/repositories/automation-credential-repository.test.ts index e098ef6888..48c4c703d5 100644 --- a/tests/backend/repositories/automation-credential-repository.test.ts +++ b/tests/backend/repositories/automation-credential-repository.test.ts @@ -9,6 +9,7 @@ import { MountedKeyFileProvider } from "../../../src/infrastructure/security/mou import { EncryptedSqliteSecretStore } from "../../../src/infrastructure/security/encrypted-sqlite-secret-store.js"; import { CredentialBroker } from "../../../src/services/credentials/credential-broker.js"; import type { SecretContext, SecretStore } from "../../../src/services/credentials/secret-store.js"; +import type { CreateAutomationCredentialInput, CredentialResolutionRequest } from "../../../src/contracts/automation-credential-types.js"; const dirs: string[] = []; @@ -29,6 +30,14 @@ async function fixture() { return { dir, dbPath, storage, repository, provider, secretStore, broker, first, second }; } +function projectCredential(input: Pick & Partial>): CreateAutomationCredentialInput { + return { ...input, scope: "project", allowedProjectIds: [], capabilities: input.capabilities ?? ["read"] }; +} + +function resolution(projectId: string, bindingKey: string, allowedKinds: string[]): CredentialResolutionRequest { + return { projectId, bindingKey, allowedKinds, requiredCapabilities: ["read"], workspaceId: "run" }; +} + afterEach(async () => { await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); @@ -37,16 +46,16 @@ describe("automation credential repository and broker", () => { it("persists only encrypted material, resolves capabilities, rotates, and audits metadata", async () => { const f = await fixture(); const secret = "plain-secret-marker"; - const created = await f.broker.create(f.first.id, { name: "Token", kind: "api-token", value: secret, capabilities: ["read"] }); + const created = await f.broker.create(f.first.id, projectCredential({ name: "Token", kind: "api-token", value: secret })); expect(created).toMatchObject({ configured: true, managementProjectId: f.first.id }); expect(JSON.stringify(created)).not.toContain(secret); const persisted = f.storage.getDatabase().prepare("SELECT * FROM automation_credential_secrets WHERE credential_id=?").get(created.id) as Record; expect(JSON.stringify(persisted)).not.toContain(secret); - f.broker.bind(f.first.id, created.id, "node.http", ["read"]); - expect((await f.broker.resolve({ projectId: f.first.id, bindingKey: "node.http", capability: "read", workspaceId: "run" })).value).toBe(secret); - const rotated = await f.broker.rotate(f.first.id, created.id, "replacement"); + f.broker.bind(f.first.id, created.id, { bindingKey: "node.http", requiredCapabilities: ["read"] }); + expect((await f.broker.resolve(resolution(f.first.id, "node.http", ["api-token"]))).value).toBe(secret); + const rotated = await f.broker.rotate(f.first.id, created.id, { value: "replacement", expectedVersion: created.version }); expect(rotated.version).toBe(2); - expect((await f.broker.resolve({ projectId: f.first.id, bindingKey: "node.http", capability: "read", workspaceId: "run" })).value).toBe("replacement"); + expect((await f.broker.resolve(resolution(f.first.id, "node.http", ["api-token"]))).value).toBe("replacement"); const event = f.storage.getDatabase().prepare("SELECT * FROM automation_credential_access_events ORDER BY created_at DESC LIMIT 1").get() as Record; expect(JSON.stringify(event)).not.toContain(secret); expect(f.storage.getDatabase().prepare("SELECT COUNT(*) AS count FROM automation_credential_rotations").get()).toMatchObject({ count: 1 }); @@ -55,11 +64,11 @@ describe("automation credential repository and broker", () => { it("fails closed for cross-project, revoked, missing, and insecure providers", async () => { const f = await fixture(); - const created = await f.broker.create(f.first.id, { name: "Token", kind: "api-token", value: "secret", capabilities: ["read"] }); - expect(() => f.broker.bind(f.second.id, created.id, "node.http", ["read"])).toThrow(/not available/); - f.broker.bind(f.first.id, created.id, "node.http", ["read"]); - f.broker.revoke(f.first.id, created.id); - await expect(f.broker.resolve({ projectId: f.first.id, bindingKey: "node.http", capability: "read", workspaceId: "run" })).rejects.toThrow(/not active/); + const created = await f.broker.create(f.first.id, projectCredential({ name: "Token", kind: "api-token", value: "secret" })); + expect(() => f.broker.bind(f.second.id, created.id, { bindingKey: "node.http", requiredCapabilities: ["read"] })).toThrow(/not available/); + f.broker.bind(f.first.id, created.id, { bindingKey: "node.http", requiredCapabilities: ["read"] }); + f.broker.revoke(f.first.id, created.id, { expectedVersion: created.version }); + await expect(f.broker.resolve(resolution(f.first.id, "node.http", ["api-token"]))).rejects.toThrow(/not active/); const insecurePath = join(f.dir, "insecure.key"); await writeFile(insecurePath, Buffer.alloc(32, 4).toString("base64"), { mode: 0o644 }); const insecureHealth = await new MountedKeyFileProvider(insecurePath).health(); @@ -79,14 +88,14 @@ describe("automation credential repository and broker", () => { allowedProjectIds: [f.second.id], capabilities: ["read"], })).rejects.toThrow(/explicit allowlist/); - const projectCredential = await f.broker.create(f.first.id, { name: "Promoted", kind: "token", value: "secret", capabilities: ["read"] }); - const promoted = await f.broker.promote(f.first.id, projectCredential.id, [f.first.id, f.second.id]); + const projectScoped = await f.broker.create(f.first.id, projectCredential({ name: "Promoted", kind: "token", value: "secret" })); + const promoted = await f.broker.promote(f.first.id, projectScoped.id, { allowedProjectIds: [f.first.id, f.second.id], expectedVersion: projectScoped.version, confirmScopeExpansion: true }); expect(promoted).toMatchObject({ scope: "global", managementProjectId: f.first.id }); - f.broker.bind(f.second.id, promoted.id, "node.global", ["read"]); - expect((await f.broker.resolve({ projectId: f.second.id, bindingKey: "node.global", capability: "read", workspaceId: "run" })).value).toBe("secret"); - expect(() => f.broker.revoke(f.second.id, promoted.id)).toThrow(/not managed/); - await expect(f.broker.rotate(f.second.id, promoted.id, "stolen")).rejects.toThrow(/not managed/); - expect(() => f.broker.restrict(f.second.id, promoted.id, [f.first.id, f.second.id], ["read"])).toThrow(/not managed/); + f.broker.bind(f.second.id, promoted.id, { bindingKey: "node.global", requiredCapabilities: ["read"] }); + expect((await f.broker.resolve(resolution(f.second.id, "node.global", ["token"]))).value).toBe("secret"); + expect(() => f.broker.revoke(f.second.id, promoted.id, { expectedVersion: promoted.version })).toThrow(/not managed/); + await expect(f.broker.rotate(f.second.id, promoted.id, { value: "stolen", expectedVersion: promoted.version })).rejects.toThrow(/not managed/); + expect(() => f.broker.restrict(f.second.id, promoted.id, { allowedProjectIds: [f.first.id, f.second.id], capabilities: ["read"], expectedVersion: promoted.version })).toThrow(/not managed/); f.storage.close(); }); @@ -114,15 +123,15 @@ describe("automation credential repository and broker", () => { SELECT RAISE(ABORT, 'forced secret insert failure'); END `); - await expect(f.broker.create(f.first.id, { name: "Token", kind: "token", value: "secret", capabilities: ["read"] })).rejects.toThrow(/forced secret insert failure/); + await expect(f.broker.create(f.first.id, projectCredential({ name: "Token", kind: "token", value: "secret" }))).rejects.toThrow(/forced secret insert failure/); expect(f.storage.getDatabase().prepare("SELECT COUNT(*) AS count FROM automation_credentials").get()).toMatchObject({ count: 0 }); f.storage.close(); }); it("rolls back version metadata and keeps the old value when envelope replacement fails", async () => { const f = await fixture(); - const created = await f.broker.create(f.first.id, { name: "Token", kind: "token", value: "original", capabilities: ["read"] }); - f.broker.bind(f.first.id, created.id, "node.http", ["read"]); + const created = await f.broker.create(f.first.id, projectCredential({ name: "Token", kind: "token", value: "original" })); + f.broker.bind(f.first.id, created.id, { bindingKey: "node.http", requiredCapabilities: ["read"] }); f.storage.getDatabase().exec(` CREATE TRIGGER reject_credential_secret_update BEFORE UPDATE ON automation_credential_secrets @@ -130,16 +139,16 @@ describe("automation credential repository and broker", () => { SELECT RAISE(ABORT, 'forced secret update failure'); END `); - await expect(f.broker.rotate(f.first.id, created.id, "replacement")).rejects.toThrow(/forced secret update failure/); + await expect(f.broker.rotate(f.first.id, created.id, { value: "replacement", expectedVersion: created.version })).rejects.toThrow(/forced secret update failure/); expect(f.repository.get(created.id)).toMatchObject({ version: 1, status: "active" }); - expect((await f.broker.resolve({ projectId: f.first.id, bindingKey: "node.http", capability: "read", workspaceId: "run" })).value).toBe("original"); + expect((await f.broker.resolve(resolution(f.first.id, "node.http", ["token"]))).value).toBe("original"); expect(f.storage.getDatabase().prepare("SELECT COUNT(*) AS count FROM automation_credential_rotations").get()).toMatchObject({ count: 0 }); f.storage.close(); }); it("rolls back scope changes when promoted-envelope persistence fails", async () => { const f = await fixture(); - const created = await f.broker.create(f.first.id, { name: "Token", kind: "token", value: "original", capabilities: ["read"] }); + const created = await f.broker.create(f.first.id, projectCredential({ name: "Token", kind: "token", value: "original" })); f.storage.getDatabase().exec(` CREATE TRIGGER reject_promoted_secret_update BEFORE UPDATE ON automation_credential_secrets @@ -147,25 +156,25 @@ describe("automation credential repository and broker", () => { SELECT RAISE(ABORT, 'forced promotion failure'); END `); - await expect(f.broker.promote(f.first.id, created.id, [f.first.id, f.second.id])).rejects.toThrow(/forced promotion failure/); + await expect(f.broker.promote(f.first.id, created.id, { allowedProjectIds: [f.first.id, f.second.id], expectedVersion: created.version, confirmScopeExpansion: true })).rejects.toThrow(/encrypted state is invalid/); expect(f.repository.get(created.id)).toMatchObject({ scope: "project", projectId: f.first.id, managementProjectId: f.first.id }); - f.broker.bind(f.first.id, created.id, "node.http", ["read"]); - expect((await f.broker.resolve({ projectId: f.first.id, bindingKey: "node.http", capability: "read", workspaceId: "run" })).value).toBe("original"); + f.broker.bind(f.first.id, created.id, { bindingKey: "node.http", requiredCapabilities: ["read"] }); + expect((await f.broker.resolve(resolution(f.first.id, "node.http", ["token"]))).value).toBe("original"); f.storage.close(); }); it("allows only one overlapping rotation to commit", async () => { const f = await fixture(); - const created = await f.broker.create(f.first.id, { name: "Token", kind: "token", value: "original", capabilities: ["read"] }); - f.broker.bind(f.first.id, created.id, "node.http", ["read"]); + const created = await f.broker.create(f.first.id, projectCredential({ name: "Token", kind: "token", value: "original" })); + f.broker.bind(f.first.id, created.id, { bindingKey: "node.http", requiredCapabilities: ["read"] }); const results = await Promise.allSettled([ - f.broker.rotate(f.first.id, created.id, "replacement-a"), - f.broker.rotate(f.first.id, created.id, "replacement-b"), + f.broker.rotate(f.first.id, created.id, { value: "replacement-a", expectedVersion: created.version }), + f.broker.rotate(f.first.id, created.id, { value: "replacement-b", expectedVersion: created.version }), ]); expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); expect(f.repository.get(created.id)).toMatchObject({ version: 2 }); - const resolved = await f.broker.resolve({ projectId: f.first.id, bindingKey: "node.http", capability: "read", workspaceId: "run" }); + const resolved = await f.broker.resolve(resolution(f.first.id, "node.http", ["token"])); expect(["replacement-a", "replacement-b"]).toContain(resolved.value); expect(f.storage.getDatabase().prepare("SELECT COUNT(*) AS count FROM automation_credential_rotations").get()).toMatchObject({ count: 1 }); f.storage.close(); @@ -173,8 +182,8 @@ describe("automation credential repository and broker", () => { it("rechecks authorization after decryption so revocation wins an in-flight resolve", async () => { const f = await fixture(); - const created = await f.broker.create(f.first.id, { name: "Token", kind: "token", value: "secret", capabilities: ["read"] }); - f.broker.bind(f.first.id, created.id, "node.http", ["read"]); + const created = await f.broker.create(f.first.id, projectCredential({ name: "Token", kind: "token", value: "secret" })); + f.broker.bind(f.first.id, created.id, { bindingKey: "node.http", requiredCapabilities: ["read"] }); let release!: () => void; let entered!: () => void; const gate = new Promise((resolve) => { release = resolve; }); @@ -192,9 +201,9 @@ describe("automation credential repository and broker", () => { }, }; const resolvingBroker = new CredentialBroker(f.repository, delayedStore, f.provider); - const pending = resolvingBroker.resolve({ projectId: f.first.id, bindingKey: "node.http", capability: "read", workspaceId: "run" }); + const pending = resolvingBroker.resolve(resolution(f.first.id, "node.http", ["token"])); await readStarted; - f.broker.revoke(f.first.id, created.id); + f.broker.revoke(f.first.id, created.id, { expectedVersion: created.version }); release(); await expect(pending).rejects.toThrow(/not active/); f.storage.close(); @@ -202,10 +211,10 @@ describe("automation credential repository and broker", () => { it("rejects malformed and oversized runtime inputs instead of coercing them", async () => { const f = await fixture(); - await expect(f.broker.create(f.first.id, { name: "Token", kind: "token", value: "x".repeat(64 * 1024 + 1) })).rejects.toThrow(/65536/); - await expect(f.broker.create(f.first.id, { name: "Token", kind: "token", value: "secret", capabilities: "read" as unknown as string[] })).rejects.toThrow(/array of strings/); - const created = await f.broker.create(f.first.id, { name: "Token", kind: "token", value: "secret", capabilities: ["read"] }); - expect(() => f.broker.bind(f.first.id, created.id, "node.http", ["read", 7] as unknown)).toThrow(/must be a string/); + await expect(f.broker.create(f.first.id, projectCredential({ name: "Token", kind: "token", value: "x".repeat(64 * 1024 + 1) }))).rejects.toThrow(/65536/); + await expect(f.broker.create(f.first.id, { ...projectCredential({ name: "Token", kind: "token", value: "secret" }), capabilities: "read" as unknown as string[] })).rejects.toThrow(/array of strings/); + const created = await f.broker.create(f.first.id, projectCredential({ name: "Token", kind: "token", value: "secret" })); + expect(() => f.broker.bind(f.first.id, created.id, { bindingKey: "node.http", requiredCapabilities: ["read", 7] as unknown as string[] })).toThrow(/must be a string/); f.storage.close(); }); }); diff --git a/tests/backend/server/automation-credential-routes.test.ts b/tests/backend/server/automation-credential-routes.test.ts index 406d8ad5e4..8854edb3c8 100644 --- a/tests/backend/server/automation-credential-routes.test.ts +++ b/tests/backend/server/automation-credential-routes.test.ts @@ -1,16 +1,121 @@ import express from "express"; import request from "supertest"; import { describe, expect, it, vi } from "vitest"; +import { CredentialConcurrentModificationError } from "../../../src/repositories/automation-credential-repository.js"; import { registerAutomationCredentialRoutes } from "../../../src/server/automation-credential-routes.js"; import { toHttpRouteError } from "../../../src/server/http-errors.js"; -import { CredentialAccessDeniedError } from "../../../src/services/credentials/credential-broker.js"; -import { CredentialConcurrentModificationError } from "../../../src/repositories/automation-credential-repository.js"; +import { + CredentialAccessDeniedError, + CredentialEncryptedStateError, + CredentialKeyCustodyUnavailableError, +} from "../../../src/services/credentials/credential-broker.js"; + +const metadata = { + id: "credential-1", + name: "Token", + kind: "api-token", + scope: "project", + projectId: "project-1", + managementProjectId: "project-1", + allowedProjectIds: [], + capabilities: ["read"], + status: "active", + configured: true, + keyId: "root", + keyVersion: 1, + version: 1, + lastValidatedAt: null, + validationStatus: "untested", + createdAt: "now", + updatedAt: "now", +}; + +function app(credentialBroker: Record) { + const application = express(); + application.use(express.json()); + registerAutomationCredentialRoutes(application, { credentialBroker } as never); + return application; +} + +describe("automation credential routes", () => { + it("passes secrets only into explicit write operations and returns metadata", async () => { + const canary = "ROUTE_SECRET_CANARY"; + const credentialBroker = { + create: vi.fn().mockResolvedValue(metadata), + rotate: vi.fn().mockResolvedValue({ ...metadata, version: 2 }), + }; + const createResponse = await request(app(credentialBroker)) + .post("/api/projects/project-1/credentials") + .send({ + name: "Token", + kind: "api-token", + value: canary, + scope: "project", + allowedProjectIds: [], + capabilities: ["read"], + }); + expect(createResponse.status).toBe(201); + expect(createResponse.body).toEqual(metadata); + expect(JSON.stringify(createResponse.body)).not.toContain(canary); + expect(credentialBroker.create).toHaveBeenCalledWith("project-1", expect.objectContaining({ value: canary })); -describe("automation credential routes",()=>{ - it("passes secret values only into write operations and returns metadata",async()=>{const metadata={id:"credential-1",name:"Token",kind:"api-token",scope:"project",projectId:"project-1",allowedProjectIds:[],capabilities:["read"],status:"active",configured:true,keyId:"root",keyVersion:1,version:1,lastValidatedAt:null,validationStatus:"untested",createdAt:"now",updatedAt:"now"};const credentialBroker={create:vi.fn().mockResolvedValue(metadata)};const app=express();app.use(express.json());registerAutomationCredentialRoutes(app,{credentialBroker} as any);const response=await request(app).post("/api/projects/project-1/credentials").send({name:"Token",kind:"api-token",value:"super-secret",capabilities:["read"]});expect(response.status).toBe(201);expect(response.body).toEqual(metadata);expect(JSON.stringify(response.body)).not.toContain("super-secret");expect(credentialBroker.create).toHaveBeenCalledWith("project-1",expect.objectContaining({value:"super-secret"}));}); + const rotateResponse = await request(app(credentialBroker)) + .post("/api/projects/project-1/credentials/credential-1/rotate") + .send({ value: canary, expectedVersion: 1 }); + expect(rotateResponse.status).toBe(200); + expect(rotateResponse.body).toMatchObject({ id: "credential-1", version: 2 }); + expect(JSON.stringify(rotateResponse.body)).not.toContain(canary); + expect(credentialBroker.rotate).toHaveBeenCalledWith("project-1", "credential-1", { value: canary, expectedVersion: 1 }); + }); + + it("forwards explicit lifecycle and compatibility request contracts", async () => { + const credentialBroker = { + updateMetadata: vi.fn().mockReturnValue({ ...metadata, name: "Renamed", version: 2 }), + test: vi.fn().mockResolvedValue({ ...metadata, validationStatus: "valid", version: 2 }), + revoke: vi.fn().mockReturnValue({ ...metadata, status: "revoked", version: 2 }), + assessCompatibility: vi.fn().mockResolvedValue({ credentialId: metadata.id, compatible: true }), + }; + await request(app(credentialBroker)).patch("/api/projects/project-1/credentials/credential-1") + .send({ name: "Renamed", expectedVersion: 1 }).expect(200); + expect(credentialBroker.updateMetadata).toHaveBeenCalledWith("project-1", "credential-1", { name: "Renamed", expectedVersion: 1 }); + await request(app(credentialBroker)).post("/api/projects/project-1/credentials/credential-1/test") + .send({ expectedVersion: 1 }).expect(200); + expect(credentialBroker.test).toHaveBeenCalledWith("project-1", "credential-1", { expectedVersion: 1 }); + await request(app(credentialBroker)).post("/api/projects/project-1/credentials/credential-1/revoke") + .send({ expectedVersion: 1 }).expect(200); + expect(credentialBroker.revoke).toHaveBeenCalledWith("project-1", "credential-1", { expectedVersion: 1 }); + await request(app(credentialBroker)).post("/api/projects/project-1/credentials/credential-1/compatibility") + .send({ allowedKinds: ["api-token"], requiredCapabilities: ["read"] }).expect(200); + expect(credentialBroker.assessCompatibility).toHaveBeenCalledWith("credential-1", { + projectId: "project-1", + allowedKinds: ["api-token"], + requiredCapabilities: ["read"], + }); + }); - it("maps credential denials and concurrent writes to explicit HTTP outcomes", () => { + it("maps credential failures to stable typed HTTP outcomes", () => { expect(toHttpRouteError(new CredentialAccessDeniedError("Credential is not managed by this project."))).toMatchObject({ status: 403 }); expect(toHttpRouteError(new CredentialConcurrentModificationError("Credential changed; retry."))).toMatchObject({ status: 409 }); + expect(toHttpRouteError(new CredentialKeyCustodyUnavailableError())).toMatchObject({ status: 503 }); + expect(toHttpRouteError(new CredentialEncryptedStateError())).toMatchObject({ status: 422 }); + }); + + it("returns actionable typed failure messages without serialized request secrets", async () => { + const canary = "ROUTE_ERROR_SECRET_CANARY"; + const credentialBroker = { + rotate: vi.fn().mockRejectedValue(new CredentialKeyCustodyUnavailableError()), + test: vi.fn().mockRejectedValue(new CredentialEncryptedStateError()), + }; + const custody = await request(app(credentialBroker)) + .post("/api/projects/project-1/credentials/credential-1/rotate") + .send({ value: canary, expectedVersion: 1 }); + expect(custody.status).toBe(503); + expect(custody.body.error).toMatch(/restore the configured secure key provider/); + expect(JSON.stringify(custody.body)).not.toContain(canary); + const invalid = await request(app(credentialBroker)) + .post("/api/projects/project-1/credentials/credential-1/test") + .send({ expectedVersion: 1 }); + expect(invalid.status).toBe(422); + expect(invalid.body.error).toMatch(/replace its value with the current version/); }); }); diff --git a/tests/backend/services/credential-broker.test.ts b/tests/backend/services/credential-broker.test.ts new file mode 100644 index 0000000000..afcb2387e1 --- /dev/null +++ b/tests/backend/services/credential-broker.test.ts @@ -0,0 +1,236 @@ +import { mkdtemp, rm, unlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { EncryptedSqliteSecretStore } from "../../../src/infrastructure/security/encrypted-sqlite-secret-store.js"; +import { MountedKeyFileProvider } from "../../../src/infrastructure/security/mounted-key-file-provider.js"; +import { AppDbStorage } from "../../../src/repositories/app-db-storage.js"; +import { AutomationCredentialRepository } from "../../../src/repositories/automation-credential-repository.js"; +import { ProjectManagementRepository } from "../../../src/repositories/project-management-repository.js"; +import { SettingsRepository } from "../../../src/repositories/settings-repository.js"; +import { AutomationAuditExportService } from "../../../src/services/automation-audit-export-service.js"; +import { + CredentialBroker, + CredentialEncryptedStateError, + CredentialKeyCustodyUnavailableError, +} from "../../../src/services/credentials/credential-broker.js"; +import { createLogger } from "../../../src/shared/logging/logger.js"; +import { runWithCorrelationId } from "../../../src/shared/logging/correlation-id.js"; + +const directories: string[] = []; + +async function fixture() { + const directory = await mkdtemp(join(tmpdir(), "credential-broker-test-")); + directories.push(directory); + const keyPath = join(directory, "root.key"); + await writeFile(keyPath, Buffer.alloc(32, 5).toString("base64"), { mode: 0o600 }); + const storage = new AppDbStorage(join(directory, "app.db")); + const projects = new ProjectManagementRepository(storage); + const managingProject = projects.createProject({ name: "Managing", sourceType: "local", sourceRef: join(directory, "managing") }); + const consumerProject = projects.createProject({ name: "Consumer", sourceType: "local", sourceRef: join(directory, "consumer") }); + const repository = new AutomationCredentialRepository(storage); + const provider = new MountedKeyFileProvider(keyPath); + const secretStore = new EncryptedSqliteSecretStore(repository, provider); + const audit = new AutomationAuditExportService(storage); + const broker = new CredentialBroker(repository, secretStore, provider, audit); + return { directory, keyPath, storage, managingProject, consumerProject, repository, secretStore, audit, broker }; +} + +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +describe("credential broker lifecycle policy", () => { + it("assesses all compatibility dimensions without resolving plaintext", async () => { + const f = await fixture(); + const created = await f.broker.create(f.managingProject.id, { + name: "Jobs API", + kind: "http.token", + value: "compatibility-secret-canary", + scope: "global", + allowedProjectIds: [f.managingProject.id, f.consumerProject.id], + capabilities: ["read", "jobs.list"], + }); + const get = vi.spyOn(f.secretStore, "get"); + + const compatible = await f.broker.assessCompatibility(created.id, { + projectId: f.consumerProject.id, + allowedKinds: ["http.token"], + requiredCapabilities: ["read", "jobs.list"], + }); + expect(compatible).toMatchObject({ + compatible: true, + backendReady: true, + configured: true, + active: true, + projectAccess: true, + kindAllowed: true, + capabilitiesAllowed: true, + missingCapabilities: [], + issues: [], + }); + + const denied = await f.broker.assessCompatibility(created.id, { + projectId: f.consumerProject.id, + allowedKinds: ["ssh-key"], + requiredCapabilities: ["read", "jobs.write"], + }); + expect(denied).toMatchObject({ compatible: false, kindAllowed: false, capabilitiesAllowed: false }); + expect(denied.missingCapabilities).toEqual(["jobs.write"]); + expect(denied.issues).toEqual(expect.arrayContaining(["kind_not_allowed", "capability_missing"])); + expect(get).not.toHaveBeenCalled(); + f.storage.close(); + }); + + it("keeps metadata immutable except for bounded names and makes restrictions monotonic and version-safe", async () => { + const f = await fixture(); + const created = await f.broker.create(f.managingProject.id, { + name: "Original", + kind: "token", + value: "metadata-secret-canary", + scope: "global", + allowedProjectIds: [f.managingProject.id, f.consumerProject.id], + capabilities: ["read", "write"], + }); + const renamed = f.broker.updateMetadata(f.managingProject.id, created.id, { name: "Renamed", expectedVersion: created.version }); + expect(renamed).toMatchObject({ name: "Renamed", kind: "token", managementProjectId: f.managingProject.id, version: 2 }); + expect(() => f.broker.updateMetadata(f.managingProject.id, created.id, { + name: "Unsafe", + expectedVersion: renamed.version, + kind: "ssh-key", + } as never)).toThrow(/unsupported fields: kind/); + + const restricted = f.broker.restrict(f.managingProject.id, created.id, { + allowedProjectIds: [f.managingProject.id], + capabilities: ["read"], + expectedVersion: renamed.version, + }); + expect(restricted).toMatchObject({ allowedProjectIds: [f.managingProject.id], capabilities: ["read"], version: 3 }); + expect(() => f.broker.restrict(f.managingProject.id, created.id, { + allowedProjectIds: [f.managingProject.id, f.consumerProject.id], + capabilities: ["read"], + expectedVersion: restricted.version, + })).toThrow(/cannot add project access/); + expect(() => f.broker.restrict(f.managingProject.id, created.id, { + allowedProjectIds: [f.managingProject.id], + capabilities: ["read", "write"], + expectedVersion: restricted.version, + })).toThrow(/cannot add capabilities/); + expect(() => f.broker.updateMetadata(f.managingProject.id, created.id, { name: "Stale", expectedVersion: renamed.version })).toThrow(/refresh its metadata/); + + const revoked = f.broker.revoke(f.managingProject.id, created.id, { expectedVersion: restricted.version }); + expect(revoked).toMatchObject({ status: "revoked", version: 4 }); + expect(f.broker.revoke(f.managingProject.id, created.id, { expectedVersion: revoked.version })).toMatchObject({ status: "revoked", version: 4 }); + expect(() => f.broker.revoke(f.managingProject.id, created.id, { expectedVersion: restricted.version })).toThrow(/refresh its metadata/); + f.storage.close(); + }); + + it("requires confirmed versioned promotion and enforces all capabilities and an allowed kind before one read", async () => { + const f = await fixture(); + const created = await f.broker.create(f.managingProject.id, { + name: "Project token", + kind: "http.token", + value: "resolution-secret-canary", + scope: "project", + allowedProjectIds: [], + capabilities: ["read", "jobs.list"], + }); + await expect(f.broker.promote(f.managingProject.id, created.id, { + allowedProjectIds: [f.managingProject.id, f.consumerProject.id], + expectedVersion: created.version, + confirmScopeExpansion: false, + })).rejects.toThrow(/confirmScopeExpansion/); + const promoted = await f.broker.promote(f.managingProject.id, created.id, { + allowedProjectIds: [f.managingProject.id, f.consumerProject.id], + expectedVersion: created.version, + confirmScopeExpansion: true, + }); + expect(promoted).toMatchObject({ scope: "global", version: 2 }); + f.broker.bind(f.consumerProject.id, promoted.id, { + bindingKey: "jobs", + requiredCapabilities: ["read", "jobs.list"], + }); + const get = vi.spyOn(f.secretStore, "get"); + await expect(f.broker.resolve({ + projectId: f.consumerProject.id, + bindingKey: "jobs", + allowedKinds: ["ssh-key"], + requiredCapabilities: ["read", "jobs.list"], + workspaceId: "run", + })).rejects.toThrow(/kind is not approved/); + await expect(f.broker.resolve({ + projectId: f.consumerProject.id, + bindingKey: "jobs", + allowedKinds: ["http.token"], + requiredCapabilities: ["read", "jobs.write"], + workspaceId: "run", + })).rejects.toThrow(/every required capability/); + expect(get).not.toHaveBeenCalled(); + expect((await f.broker.resolve({ + projectId: f.consumerProject.id, + bindingKey: "jobs", + allowedKinds: ["http.token"], + requiredCapabilities: ["read", "jobs.list"], + workspaceId: "run", + })).value).toBe("resolution-secret-canary"); + expect(get).toHaveBeenCalledTimes(1); + f.storage.close(); + }); + + it("classifies validation failures without leaking request secrets to errors, logs, audits, or settings", async () => { + const f = await fixture(); + const canary = "CREDENTIAL_REQUEST_SECRET_CANARY"; + const created = await runWithCorrelationId("credential-lifecycle-correlation", () => f.broker.create(f.managingProject.id, { + name: "Tamper test", + kind: "token", + value: canary, + scope: "project", + allowedProjectIds: [], + capabilities: ["read"], + })); + f.storage.getDatabase().prepare("UPDATE automation_credential_secrets SET auth_tag = ? WHERE credential_id = ?") + .run(Buffer.alloc(16, 1), created.id); + let serializedError = ""; + try { + await f.broker.test(f.managingProject.id, created.id, { expectedVersion: created.version }); + } catch (error) { + expect(error).toBeInstanceOf(CredentialEncryptedStateError); + serializedError = JSON.stringify(error, Object.getOwnPropertyNames(error)); + } + expect(f.repository.get(created.id)).toMatchObject({ validationStatus: "invalid", version: 2 }); + expect(f.audit.list().find((record) => record.action === "credential.create")?.correlationId).toBe("credential-lifecycle-correlation"); + + const capturedLogs: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation(((chunk: string | Uint8Array) => { + capturedLogs.push(String(chunk)); + return true; + }) as typeof process.stderr.write); + vi.stubEnv("CODEUX_FORCE_LOG_LEVEL", "info"); + createLogger({ environment: "production", consoleLogLevel: "info", consoleLogMode: "full" }) + .info("credential lifecycle canary", { credentialValue: canary }); + const settings = new SettingsRepository(join(f.directory, "settings.db")).getSystemSettings(); + const observable = JSON.stringify({ serializedError, logs: capturedLogs, audit: f.audit.list(), settings }); + expect(observable).not.toContain(canary); + expect(observable).toContain("[REDACTED]"); + f.storage.close(); + }); + + it("marks validation unavailable when key custody cannot recover the envelope", async () => { + const f = await fixture(); + const created = await f.broker.create(f.managingProject.id, { + name: "Custody test", + kind: "token", + value: "custody-secret-canary", + scope: "project", + allowedProjectIds: [], + capabilities: ["read"], + }); + await unlink(f.keyPath); + await expect(f.broker.test(f.managingProject.id, created.id, { expectedVersion: created.version })) + .rejects.toBeInstanceOf(CredentialKeyCustodyUnavailableError); + expect(f.repository.get(created.id)).toMatchObject({ validationStatus: "unavailable", version: 2 }); + f.storage.close(); + }); +}); diff --git a/tests/backend/services/credential-encryption.test.ts b/tests/backend/services/credential-encryption.test.ts index 70a32906c6..28811b6327 100644 --- a/tests/backend/services/credential-encryption.test.ts +++ b/tests/backend/services/credential-encryption.test.ts @@ -9,4 +9,5 @@ describe("credential envelope encryption",()=>{ it("round trips with unique payload and wrap nonces",()=>{const first=encryptEnvelope(context,Buffer.from("secret"),root);const second=encryptEnvelope(context,Buffer.from("secret"),root);expect(decryptEnvelope(context,first,root).toString()).toBe("secret");expect(first.nonce.equals(second.nonce)).toBe(false);expect(first.wrapNonce.equals(second.wrapNonce)).toBe(false);expect(first.ciphertext.toString("utf8")).not.toContain("secret");}); it.each(["ciphertext","authTag","wrappedDataKey","wrapAuthTag"] as const)("rejects %s tampering",(field)=>{const envelope=encryptEnvelope(context,Buffer.from("secret"),root);envelope[field][0]^=1;expect(()=>decryptEnvelope(context,envelope,root)).toThrow();}); it("rejects wrong keys and authenticated context",()=>{const envelope=encryptEnvelope(context,Buffer.from("secret"),root);expect(()=>decryptEnvelope(context,envelope,{...root,key:randomBytes(32)})).toThrow();expect(()=>decryptEnvelope({...context,workspaceId:"other"},envelope,root)).toThrow();}); + it("does not include plaintext canaries in serialized cryptographic errors",()=>{const canary="ENCRYPTION_SECRET_CANARY";const envelope=encryptEnvelope(context,Buffer.from(canary),root);envelope.authTag[0]^=1;let serialized="";try{decryptEnvelope(context,envelope,root);}catch(error){serialized=JSON.stringify(error,Object.getOwnPropertyNames(error));}expect(serialized).not.toContain(canary);expect(envelope.ciphertext.toString("utf8")).not.toContain(canary);}); }); diff --git a/tests/backend/services/credentialed-automation-e2e.test.ts b/tests/backend/services/credentialed-automation-e2e.test.ts index ad44f25ecf..3671539140 100644 --- a/tests/backend/services/credentialed-automation-e2e.test.ts +++ b/tests/backend/services/credentialed-automation-e2e.test.ts @@ -156,9 +156,9 @@ describe("credentialed automation authoring-to-execution", () => { let credentialRepository = new AutomationCredentialRepository(storage); let broker = new CredentialBroker(credentialRepository, new EncryptedSqliteSecretStore(credentialRepository, keyProvider), keyProvider, audit); const credential = await broker.create(project.id, { - name: "Mock jobs API", kind: "http", value: FIRST_SECRET_CANARY, capabilities: ["read"], + name: "Mock jobs API", kind: "http", value: FIRST_SECRET_CANARY, scope: "project", allowedProjectIds: [], capabilities: ["read"], }); - broker.bind(project.id, credential.id, "jobs-api", ["read"]); + broker.bind(project.id, credential.id, { bindingKey: "jobs-api", requiredCapabilities: ["read"] }); const fixtures = JSON.parse(await fs.readFile(path.resolve("tests/e2e/fixtures/headless-automation-records.json"), "utf8")) as JobFixture[]; expect(fixtures).toHaveLength(EXPECTED_RECORD_COUNT); @@ -349,8 +349,8 @@ describe("credentialed automation authoring-to-execution", () => { await expect(customRuntime.execute({ projectId: project.id, nodeType: "custom.fixture-record-selector", version: 1, input: {}, config: {}, credentialBindings: { jobs: "missing-credential" }, workspaceId: "missing", invocationId: "missing", correlationId: "missing" })) .rejects.toThrow(/credential is missing/i); - await broker.rotate(project.id, credential.id, ROTATED_SECRET_CANARY); - const rotated = await broker.resolve({ projectId: project.id, bindingKey: "jobs-api", capability: "read", workspaceId: "rotation" }); + await broker.rotate(project.id, credential.id, { value: ROTATED_SECRET_CANARY, expectedVersion: credential.version }); + const rotated = await broker.resolve({ projectId: project.id, bindingKey: "jobs-api", requiredCapabilities: ["read"], allowedKinds: ["http"], workspaceId: "rotation" }); await jobApi.authenticate(rotated.value, rotated.version); expect(jobApi.authenticate).toHaveBeenCalledWith(ROTATED_SECRET_CANARY, 2); diagnosticMode = true; @@ -366,7 +366,7 @@ describe("credentialed automation authoring-to-execution", () => { publicationId: latestAfterRollback.id, runId, nodeId: "send-unavailable", logicalItem: "provider-unavailable", effectType: "email", payload: { to: "nobody@example.test" } }); expect(failedDelivery).toMatchObject({ status: "failed", attemptCount: 1 }); - broker.revoke(project.id, credential.id); + broker.revoke(project.id, credential.id, { expectedVersion: rotated.version }); await expect(customRuntime.execute({ projectId: project.id, nodeType: "custom.fixture-record-selector", version: 1, input: {}, config: {}, credentialBindings: { jobs: credential.id }, workspaceId: "revoked", invocationId: "revoked", correlationId: "revoked" })) .rejects.toThrow(/not active/i); diff --git a/tests/backend/services/node-flow-runtime-service.test.ts b/tests/backend/services/node-flow-runtime-service.test.ts index 8bf00721c3..7c48f1417f 100644 --- a/tests/backend/services/node-flow-runtime-service.test.ts +++ b/tests/backend/services/node-flow-runtime-service.test.ts @@ -292,7 +292,7 @@ describe("NodeFlowRuntimeService", () => { trackPromptInInvocation: false, trackAssistantInInvocation: false, })); - expect(resolveCredentialId).toHaveBeenCalledWith(expect.objectContaining({projectId:project.id,credentialId:"credential-1",bindingKey:`${flow.id}:prompt:provider`,capability:"read"})); + expect(resolveCredentialId).toHaveBeenCalledWith(expect.objectContaining({projectId:project.id,credentialId:"credential-1",bindingKey:`${flow.id}:prompt:provider`,requiredCapabilities:["read"],allowedKinds:["provider"]})); const promptRun = result.nodeRuns.find((nodeRun) => nodeRun.nodeId === "prompt"); expect(promptRun?.executionInvocationId).toMatch(/^xi_/); expect(promptRun?.output).toMatchObject({ text: "provider answer", nativeSessionId: "native-1" }); From ed6897ccb48f4d357c52e55b7133a0146982ec56 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 21:34:19 +0000 Subject: [PATCH 05/22] fix(task T02): address qa review via codex --- .../docs/operations-credential-security.mdx | 2 +- docs-web/operations/credential-security.md | 2 +- docs/operations/credential-security.md | 2 +- src/services/credentials/credential-broker.ts | 15 +++++-- .../services/credential-broker.test.ts | 44 ++++++++++++++++++- 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/docs-web/content/docs/operations-credential-security.mdx b/docs-web/content/docs/operations-credential-security.mdx index 491819bb32..626368d54d 100644 --- a/docs-web/content/docs/operations-credential-security.mdx +++ b/docs-web/content/docs/operations-credential-security.mdx @@ -39,6 +39,6 @@ Legacy global records use their first valid allowlisted project as the migrated ## Dashboard API -Credential management uses project-scoped dashboard routes. The API includes create, bounded-name update, bind, metadata-only compatibility assessment, test, rotate, replace, revoke, confirmed promotion, and monotonic restriction. Compatibility checks backend readiness, configured/active state, project access, allowed kinds, and all required capabilities without reading plaintext. List, health, compatibility, and mutation responses never contain secret values; secrets are accepted only by create, rotate, and replace operations. +Credential management uses project-scoped dashboard routes. The API includes create, bounded-name update, bind, metadata-only compatibility assessment, test, rotate, replace, revoke, confirmed promotion, and monotonic restriction. Compatibility checks backend readiness, configured/active state, project access, allowed kinds, and all required capabilities without reading plaintext. Backend readiness requires an available, secure backend with a non-empty key ID and a reported key version; missing identity metadata produces `backend_unavailable`. List, health, compatibility, and mutation responses never contain secret values; secrets are accepted only by create, rotate, and replace operations. Validation failures return `400`, project/management denials return `403`, concurrent-write conflicts return `409`, invalid encrypted state returns `422`, and unavailable key custody returns `503` with a safe recovery message. diff --git a/docs-web/operations/credential-security.md b/docs-web/operations/credential-security.md index 491819bb32..626368d54d 100644 --- a/docs-web/operations/credential-security.md +++ b/docs-web/operations/credential-security.md @@ -39,6 +39,6 @@ Legacy global records use their first valid allowlisted project as the migrated ## Dashboard API -Credential management uses project-scoped dashboard routes. The API includes create, bounded-name update, bind, metadata-only compatibility assessment, test, rotate, replace, revoke, confirmed promotion, and monotonic restriction. Compatibility checks backend readiness, configured/active state, project access, allowed kinds, and all required capabilities without reading plaintext. List, health, compatibility, and mutation responses never contain secret values; secrets are accepted only by create, rotate, and replace operations. +Credential management uses project-scoped dashboard routes. The API includes create, bounded-name update, bind, metadata-only compatibility assessment, test, rotate, replace, revoke, confirmed promotion, and monotonic restriction. Compatibility checks backend readiness, configured/active state, project access, allowed kinds, and all required capabilities without reading plaintext. Backend readiness requires an available, secure backend with a non-empty key ID and a reported key version; missing identity metadata produces `backend_unavailable`. List, health, compatibility, and mutation responses never contain secret values; secrets are accepted only by create, rotate, and replace operations. Validation failures return `400`, project/management denials return `403`, concurrent-write conflicts return `409`, invalid encrypted state returns `422`, and unavailable key custody returns `503` with a safe recovery message. diff --git a/docs/operations/credential-security.md b/docs/operations/credential-security.md index d94f52d87b..fc895dbf99 100644 --- a/docs/operations/credential-security.md +++ b/docs/operations/credential-security.md @@ -45,6 +45,6 @@ Existing global credentials created before management ownership was stored are m ## API surface -Project-scoped routes live under `/api/projects/:projectId/credentials`. Supported operations are create, bounded-name update (`PATCH /:credentialId`), bind, metadata-only compatibility assessment, test, rotate, replace, revoke, promote, and restrict. Compatibility evaluates key-backend readiness, configuration, active status, project access, allowed kinds, and all required capabilities without resolving plaintext. List, compatibility, health, and mutation responses return metadata or policy results only. Existing dashboard authentication and remote credential-management guards apply before these routes. +Project-scoped routes live under `/api/projects/:projectId/credentials`. Supported operations are create, bounded-name update (`PATCH /:credentialId`), bind, metadata-only compatibility assessment, test, rotate, replace, revoke, promote, and restrict. Compatibility evaluates key-backend readiness, configuration, active status, project access, allowed kinds, and all required capabilities without resolving plaintext. A backend is ready only when it is available and secure and reports both a non-empty key ID and a key version; missing key identity metadata produces the stable `backend_unavailable` compatibility issue. List, compatibility, health, and mutation responses return metadata or policy results only. Existing dashboard authentication and remote credential-management guards apply before these routes. Runtime validation failures return `400`, project/management denials return `403`, compare-and-swap conflicts return `409`, invalid encrypted state returns `422`, and unavailable key custody returns an actionable `503` response. diff --git a/src/services/credentials/credential-broker.ts b/src/services/credentials/credential-broker.ts index a6a6de8f78..e4daa03bcc 100644 --- a/src/services/credentials/credential-broker.ts +++ b/src/services/credentials/credential-broker.ts @@ -110,6 +110,14 @@ function sameCredentialSnapshot(left: AutomationCredentialMetadata, right: Autom && left.capabilities.every((capability, index) => capability === right.capabilities[index]); } +function hasBackendIdentity(health: CredentialBackendHealth): boolean { + return typeof health.keyId === "string" && health.keyId.length > 0 && health.keyVersion !== null; +} + +function isBackendReady(health: CredentialBackendHealth): boolean { + return health.available && health.secure && hasBackendIdentity(health); +} + export class CredentialBroker { constructor( private readonly repository: AutomationCredentialRepository, @@ -240,7 +248,8 @@ export class CredentialBroker { : [...requiredCapabilities]; const capabilitiesAllowed = projectAccess && missingCapabilities.length === 0; const issues: AutomationCredentialCompatibilityIssue[] = []; - if (!health.available) issues.push("backend_unavailable"); + const backendReady = isBackendReady(health); + if (!health.available || !hasBackendIdentity(health)) issues.push("backend_unavailable"); else if (!health.secure) issues.push("backend_insecure"); if (!configured) issues.push("not_configured"); if (!active) issues.push("not_active"); @@ -251,7 +260,7 @@ export class CredentialBroker { credentialId, projectId, compatible: issues.length === 0, - backendReady: health.available && health.secure, + backendReady, configured, active, projectAccess, @@ -630,7 +639,7 @@ export class CredentialBroker { private async requireBackendReady(): Promise { const health = await this.safeHealth(); - if (!health.available || !health.secure || !health.keyId || health.keyVersion === null) { + if (!isBackendReady(health)) { throw new CredentialKeyCustodyUnavailableError(); } } diff --git a/tests/backend/services/credential-broker.test.ts b/tests/backend/services/credential-broker.test.ts index afcb2387e1..6a6342687c 100644 --- a/tests/backend/services/credential-broker.test.ts +++ b/tests/backend/services/credential-broker.test.ts @@ -33,7 +33,7 @@ async function fixture() { const secretStore = new EncryptedSqliteSecretStore(repository, provider); const audit = new AutomationAuditExportService(storage); const broker = new CredentialBroker(repository, secretStore, provider, audit); - return { directory, keyPath, storage, managingProject, consumerProject, repository, secretStore, audit, broker }; + return { directory, keyPath, storage, managingProject, consumerProject, repository, provider, secretStore, audit, broker }; } afterEach(async () => { @@ -84,6 +84,48 @@ describe("credential broker lifecycle policy", () => { f.storage.close(); }); + it.each([ + { keyId: "", keyVersion: 1 }, + { keyId: "mounted-file", keyVersion: null }, + ])("reports missing backend identity metadata as unavailable without resolving plaintext", async ({ keyId, keyVersion }) => { + const f = await fixture(); + const created = await f.broker.create(f.managingProject.id, { + name: "Jobs API", + kind: "http.token", + value: "backend-readiness-secret-canary", + scope: "project", + allowedProjectIds: [], + capabilities: ["jobs.list"], + }); + vi.spyOn(f.provider, "health").mockResolvedValue({ + available: true, + secure: true, + provider: f.provider.providerName, + keyId, + keyVersion, + }); + const get = vi.spyOn(f.secretStore, "get"); + + const assessment = await f.broker.assessCompatibility(created.id, { + projectId: f.managingProject.id, + allowedKinds: ["http.token"], + requiredCapabilities: ["jobs.list"], + }); + + expect(assessment).toMatchObject({ + compatible: false, + backendReady: false, + configured: true, + active: true, + projectAccess: true, + kindAllowed: true, + capabilitiesAllowed: true, + issues: ["backend_unavailable"], + }); + expect(get).not.toHaveBeenCalled(); + f.storage.close(); + }); + it("keeps metadata immutable except for bounded names and makes restrictions monotonic and version-safe", async () => { const f = await fixture(); const created = await f.broker.create(f.managingProject.id, { From 39dfa1e136c765ed9918bdebd17cade9f0e92974 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 22:07:03 +0000 Subject: [PATCH 06/22] feat(task T03): implement via codex --- docs-web/architecture/custom-nodes.md | 2 +- docs-web/architecture/node-flow-foundation.md | 6 +- .../docs/architecture-custom-nodes.mdx | 2 +- .../architecture-node-flow-foundation.mdx | 6 +- .../docs/operations-credential-security.mdx | 2 + docs-web/operations/credential-security.md | 2 + docs/architecture/custom-nodes.md | 2 + docs/architecture/node-flow-foundation.md | 5 + docs/operations/credential-security.md | 2 + src/contracts/custom-node-types.ts | 5 +- src/contracts/node-definition-types.ts | 1 + src/contracts/node-flow-types.ts | 17 ++ .../node-flows/node-definition-registry.ts | 56 ++++- src/domain/node-flows/node-flow-validation.ts | 7 +- src/mcp/management/node-flow-actions.ts | 66 +++--- src/server/node-flow-routes.ts | 40 ++-- src/services/node-flow-agent-skill-service.ts | 2 +- src/services/node-flow-runtime-service.ts | 43 +++- src/services/node-flow-service.ts | 176 ++++++++++++---- .../node-flows/node-flow-validation.test.ts | 36 ++++ .../services/node-flow-builtins.test.ts | 36 ++++ .../node-flow-runtime-service.test.ts | 94 +++++++++ .../services/node-flow-service.test.ts | 196 ++++++++++++++++-- 23 files changed, 678 insertions(+), 126 deletions(-) diff --git a/docs-web/architecture/custom-nodes.md b/docs-web/architecture/custom-nodes.md index c30db621c6..ce72cf783c 100644 --- a/docs-web/architecture/custom-nodes.md +++ b/docs-web/architecture/custom-nodes.md @@ -14,7 +14,7 @@ The SDK exposes immutable input/config, correlation and invocation ids, cancella Validation fails closed on malformed schemas or identity, undeclared capabilities, excessive resources, symlinks, unpinned dependencies, lockfile drift, a modified trusted Docker recipe, prohibited APIs, vulnerability-audit failure, TypeScript or deterministic-test failure, fixture mismatch, output-schema failure, resource/network-policy failure, or secret-canary leakage. The exact recipe restores locked dependencies with lifecycle scripts disabled, then performs typecheck, build, and tests in a network-disabled stage. The vulnerability check is an injected governed hook; validation fails when it is not configured. -A passed revision produces an immutable content-addressed envelope with source and build digests, immutable image id, dependency inventory, validation report, creator/invocation/correlation metadata, and declared capabilities. Publication registers only its typed definition and digest. Flow graphs never embed custom source. +A passed revision produces an immutable content-addressed envelope with source and build digests, immutable image id, dependency inventory, validation report, creator/invocation/correlation metadata, and declared capabilities. Publication registers only its typed definition and digest. Flow graphs never embed custom source. Existing schema-v1 manifests keep their singular per-slot `requiredCapability`; registration normalizes it into the definition's explicit `requiredCapabilities` list so review and runtime enforce the same policy without a schema-version break. ## Isolated execution diff --git a/docs-web/architecture/node-flow-foundation.md b/docs-web/architecture/node-flow-foundation.md index 35379d6594..5adbbe2e5d 100644 --- a/docs-web/architecture/node-flow-foundation.md +++ b/docs-web/architecture/node-flow-foundation.md @@ -4,7 +4,7 @@ Code UX uses one project-owned Graph v2 contract across the dashboard, backend, ## Registry and executable handlers -The definition registry is the authority for the palette and runtime. Manifests provide configuration/UI schemas, ports, credential slots, capabilities, side effects, default policy, documentation, deprecation, executable state, and execution kind. +The definition registry is the authority for the palette and runtime. Manifests provide configuration/UI schemas, ports, credential slots, capabilities, side effects, default policy, documentation, deprecation, executable state, and execution kind. Every credential slot explicitly declares required/optional state plus bounded, non-empty allowed kinds and required capabilities. The governed built-ins with registered handlers are: @@ -20,7 +20,9 @@ A validated custom definition becomes executable only after its immutable artifa Validation resolves each definition and checks configuration, port handles and schemas, policies, graph bounds, and cycles. Migrated Graph v1 and canonical Graph v2 inputs fail closed: malformed nodes, edges, ports, credential bindings, definition references, capabilities, policies, schemas, and metadata produce deterministic field-level issues at their original paths without discarding safe siblings. Repeated validation preserves issue ordering. It rejects plaintext secret-shaped fields and generated or custom source in graph JSON. The dashboard receives credential binding ids and metadata-only states; resolved values remain behind the credential broker. Credential values and secret-shaped payloads are redacted before invocation messages, attempts, diagnostics, route responses, and debugger output are persisted or rendered. -Draft review reports requested capabilities, side effects, credential status, policy findings, and a non-executing dry run. Publication requires the current `draftRevision`, a valid graph and policy review, and every required credential binding. Runs select an immutable pinned or latest-published snapshot. +Draft review reports requested capabilities, side effects, credential status, policy findings, and a non-executing dry run. For each bound slot it uses the credential broker's metadata-only compatibility contract and reports backend readiness, configured/active state, project access, kind and capability compatibility, and missing capabilities. Stable findings for missing required bindings and every denial block draft and legacy create/update publication; an unbound optional slot remains valid. + +At runtime the current versioned definition is checked again before an executor runs. Undeclared, duplicate, or newly required-but-missing slots fail closed, and the broker receives the same allowed kinds and required capabilities before one authorized secret read. Revocation, restriction, replacement, access changes, and encrypted-backend failure therefore stop execution before the node executor. Graph `credentialBindings` remain canonical; the legacy credential-request endpoint explicitly reports that it is non-persistent and never changes them. Runs select an immutable pinned or latest-published snapshot. ## Project workspace and migration diff --git a/docs-web/content/docs/architecture-custom-nodes.mdx b/docs-web/content/docs/architecture-custom-nodes.mdx index c30db621c6..ce72cf783c 100644 --- a/docs-web/content/docs/architecture-custom-nodes.mdx +++ b/docs-web/content/docs/architecture-custom-nodes.mdx @@ -14,7 +14,7 @@ The SDK exposes immutable input/config, correlation and invocation ids, cancella Validation fails closed on malformed schemas or identity, undeclared capabilities, excessive resources, symlinks, unpinned dependencies, lockfile drift, a modified trusted Docker recipe, prohibited APIs, vulnerability-audit failure, TypeScript or deterministic-test failure, fixture mismatch, output-schema failure, resource/network-policy failure, or secret-canary leakage. The exact recipe restores locked dependencies with lifecycle scripts disabled, then performs typecheck, build, and tests in a network-disabled stage. The vulnerability check is an injected governed hook; validation fails when it is not configured. -A passed revision produces an immutable content-addressed envelope with source and build digests, immutable image id, dependency inventory, validation report, creator/invocation/correlation metadata, and declared capabilities. Publication registers only its typed definition and digest. Flow graphs never embed custom source. +A passed revision produces an immutable content-addressed envelope with source and build digests, immutable image id, dependency inventory, validation report, creator/invocation/correlation metadata, and declared capabilities. Publication registers only its typed definition and digest. Flow graphs never embed custom source. Existing schema-v1 manifests keep their singular per-slot `requiredCapability`; registration normalizes it into the definition's explicit `requiredCapabilities` list so review and runtime enforce the same policy without a schema-version break. ## Isolated execution diff --git a/docs-web/content/docs/architecture-node-flow-foundation.mdx b/docs-web/content/docs/architecture-node-flow-foundation.mdx index 35379d6594..5adbbe2e5d 100644 --- a/docs-web/content/docs/architecture-node-flow-foundation.mdx +++ b/docs-web/content/docs/architecture-node-flow-foundation.mdx @@ -4,7 +4,7 @@ Code UX uses one project-owned Graph v2 contract across the dashboard, backend, ## Registry and executable handlers -The definition registry is the authority for the palette and runtime. Manifests provide configuration/UI schemas, ports, credential slots, capabilities, side effects, default policy, documentation, deprecation, executable state, and execution kind. +The definition registry is the authority for the palette and runtime. Manifests provide configuration/UI schemas, ports, credential slots, capabilities, side effects, default policy, documentation, deprecation, executable state, and execution kind. Every credential slot explicitly declares required/optional state plus bounded, non-empty allowed kinds and required capabilities. The governed built-ins with registered handlers are: @@ -20,7 +20,9 @@ A validated custom definition becomes executable only after its immutable artifa Validation resolves each definition and checks configuration, port handles and schemas, policies, graph bounds, and cycles. Migrated Graph v1 and canonical Graph v2 inputs fail closed: malformed nodes, edges, ports, credential bindings, definition references, capabilities, policies, schemas, and metadata produce deterministic field-level issues at their original paths without discarding safe siblings. Repeated validation preserves issue ordering. It rejects plaintext secret-shaped fields and generated or custom source in graph JSON. The dashboard receives credential binding ids and metadata-only states; resolved values remain behind the credential broker. Credential values and secret-shaped payloads are redacted before invocation messages, attempts, diagnostics, route responses, and debugger output are persisted or rendered. -Draft review reports requested capabilities, side effects, credential status, policy findings, and a non-executing dry run. Publication requires the current `draftRevision`, a valid graph and policy review, and every required credential binding. Runs select an immutable pinned or latest-published snapshot. +Draft review reports requested capabilities, side effects, credential status, policy findings, and a non-executing dry run. For each bound slot it uses the credential broker's metadata-only compatibility contract and reports backend readiness, configured/active state, project access, kind and capability compatibility, and missing capabilities. Stable findings for missing required bindings and every denial block draft and legacy create/update publication; an unbound optional slot remains valid. + +At runtime the current versioned definition is checked again before an executor runs. Undeclared, duplicate, or newly required-but-missing slots fail closed, and the broker receives the same allowed kinds and required capabilities before one authorized secret read. Revocation, restriction, replacement, access changes, and encrypted-backend failure therefore stop execution before the node executor. Graph `credentialBindings` remain canonical; the legacy credential-request endpoint explicitly reports that it is non-persistent and never changes them. Runs select an immutable pinned or latest-published snapshot. ## Project workspace and migration diff --git a/docs-web/content/docs/operations-credential-security.mdx b/docs-web/content/docs/operations-credential-security.mdx index 87b8cc36ec..dc46084e79 100644 --- a/docs-web/content/docs/operations-credential-security.mdx +++ b/docs-web/content/docs/operations-credential-security.mdx @@ -9,6 +9,8 @@ Code UX resolves canonical node credential IDs and named project binding keys th - The credential kind must be allowed, and both the binding and credential must approve every declared capability before one secret read. - Revoked, unavailable, missing, cross-project, or insufficiently capable credentials fail closed. +Node-flow definition slots explicitly declare required/optional state, allowed kinds, and required capabilities. Draft review and every publication path use the broker's metadata-only compatibility assessment; runtime sends the same declaration to direct credential-id resolution immediately before execution. Graph `credentialBindings` are canonical. The legacy credential-request endpoint records no binding and identifies its result as non-persistent. + Create, rotate, and replace requests are write-only. API responses contain configuration and status metadata but never stored values. Create requests explicitly declare kind, scope, capabilities, and an allowlist (empty for project credentials). Runtime validation bounds names, identifiers, capabilities, list counts, and secret size (64 KiB UTF-8). Malformed arrays, unknown mutation fields, and control characters are rejected rather than coerced. diff --git a/docs-web/operations/credential-security.md b/docs-web/operations/credential-security.md index 87b8cc36ec..dc46084e79 100644 --- a/docs-web/operations/credential-security.md +++ b/docs-web/operations/credential-security.md @@ -9,6 +9,8 @@ Code UX resolves canonical node credential IDs and named project binding keys th - The credential kind must be allowed, and both the binding and credential must approve every declared capability before one secret read. - Revoked, unavailable, missing, cross-project, or insufficiently capable credentials fail closed. +Node-flow definition slots explicitly declare required/optional state, allowed kinds, and required capabilities. Draft review and every publication path use the broker's metadata-only compatibility assessment; runtime sends the same declaration to direct credential-id resolution immediately before execution. Graph `credentialBindings` are canonical. The legacy credential-request endpoint records no binding and identifies its result as non-persistent. + Create, rotate, and replace requests are write-only. API responses contain configuration and status metadata but never stored values. Create requests explicitly declare kind, scope, capabilities, and an allowlist (empty for project credentials). Runtime validation bounds names, identifiers, capabilities, list counts, and secret size (64 KiB UTF-8). Malformed arrays, unknown mutation fields, and control characters are rejected rather than coerced. diff --git a/docs/architecture/custom-nodes.md b/docs/architecture/custom-nodes.md index 32ee0d7378..588b3c2db7 100644 --- a/docs/architecture/custom-nodes.md +++ b/docs/architecture/custom-nodes.md @@ -32,6 +32,8 @@ The handler receives only `NodeExecutionContext`: immutable JSON input/config, c Any failed check records a `failed` report and no artifact. A passed build records dependency inventory, source revision, deterministic build digest, Docker image id, validation report, creator/invocation/correlation metadata, manifest, and declared capabilities. The artifact envelope is content-addressed and immutable. Publication stores only its digest and registers a typed `custom.*@version` definition; flow graph JSON contains the definition reference and configuration, never source. +Schema-v1 custom manifests retain their singular per-slot `requiredCapability`. Registration normalizes it into the node-definition slot's explicit `requiredCapabilities` list, alongside allowed credential kinds and required/optional state, so existing published artifacts use the same review and runtime policy as built-ins without a manifest-version break. + The audit hook is intentionally injected so custom nodes consume the governed dependency policy instead of creating a second vulnerability policy. With no hook, validation fails. ## Runtime boundary diff --git a/docs/architecture/node-flow-foundation.md b/docs/architecture/node-flow-foundation.md index 5c2d24744d..77ffbf50c9 100644 --- a/docs/architecture/node-flow-foundation.md +++ b/docs/architecture/node-flow-foundation.md @@ -27,6 +27,7 @@ Run records are persisted as `NodeFlowRunRecord` and `NodeFlowNodeRunRecord`. Bo - widget fields have required id, label, type, and select option metadata - widget default values are JSON-safe and match the field type - malformed v1 and v2 node, edge, port, credential-binding, definition-reference, capability, policy, schema, and metadata members produce stable issues at their original paths +- credential bindings reference only slots declared by the versioned definition, whose allowed kinds and required capabilities are explicit, non-empty, and bounded Invalid graphs throw a `ValidationError` with field-level details when persistence is attempted. The validation route returns the same structured issue list without writing data. @@ -56,6 +57,8 @@ Versioned custom definitions can execute through the custom-node runtime after v Provider and HTTP nodes create linked child `execution_invocations` rows with `type = "node_flow_node"`. Prompt text and HTTP secrets are not written to invocation messages; persisted run inputs, outputs, node payloads, trigger payloads, and route responses are masked for secret-like keys. +Before an executor receives a credential, the runtime resolves the node's current versioned definition, rejects undeclared, duplicate, or newly required-but-missing slots, and passes that slot's exact allowed kinds and required capabilities to the credential broker. The broker performs authorization and one secret read, rechecks authorization after decryption, and fails closed if the credential was revoked, restricted, replaced, became inaccessible, or its encrypted backend changed after review. Optional unbound slots remain valid and do not trigger a secret read. + Failed nodes stop downstream descendants by default and persist skipped node runs. A node can set `data.continueOnError = true` to persist its own failure output while allowing downstream nodes to continue. ## Persistence @@ -128,6 +131,8 @@ Validation resolves definitions and checks configuration, handles, policies, gra Draft review combines structural validation, capability and side-effect policy findings, credential-slot status, and a non-executing dry run. The dashboard receives credential ids and status metadata only, never resolved secret values. Publication requires the current revision, a valid policy review, and all credential requirements bound. Runs resolve immutable pinned or latest-published snapshots. +Credential review uses the credential broker's metadata-only compatibility contract for every bound slot. Results expose backend readiness, configured and active state, project access, kind compatibility, capability compatibility, and missing capabilities. Missing required bindings and every compatibility denial have stable policy-finding codes and block both draft publication and the legacy create/update publication path; an unbound optional slot does not. `POST /api/node-flow-drafts/:flowId/credential-requests` remains a compatibility-only, non-persistent request and explicitly reports that it did not change `NodeFlowNode.credentialBindings`, which is the sole binding authority. + The run debugger reads persisted runs, node runs, numbered attempts, approvals, retry decisions, invocation links, timing, and cancellation state. Responses and persisted payloads are redacted before display. Scheduling delegates to the scheduler and retains pinned-versus-latest publication semantics. Outside development, the dashboard route requires `VITE_CODEUX_FEATURE_NODES`, `VITE_CODEUX_NODE_FLOW_BACKEND`, and `VITE_CODEUX_AUTOMATION_SECURITY` to resolve enabled. Runtime availability remains dependency-specific: providers, credential resolution, egress policy, approval/outbox, webhook configuration, and the custom-node runtime must be configured for definitions that use them. diff --git a/docs/operations/credential-security.md b/docs/operations/credential-security.md index 154a0383e1..3d58640f78 100644 --- a/docs/operations/credential-security.md +++ b/docs/operations/credential-security.md @@ -9,6 +9,8 @@ Code UX stores automation credentials through a broker rather than exposing secr - Resolution succeeds only when the credential kind is allowed and both the credential and binding approve every declared capability. Authorization is completed before the broker performs its single secret read. - Revoked, unavailable, missing, cross-project, or insufficiently capable credentials fail closed. +Node-flow definition slots explicitly declare required/optional state, allowed kinds, and required capabilities. Draft review and every publication path use the broker's metadata-only compatibility assessment; runtime sends the same declaration to direct credential-id resolution immediately before execution. Graph `credentialBindings` are canonical. The legacy credential-request endpoint records no binding and identifies its result as non-persistent. + The dashboard accepts secret values only on create, rotate, and replace requests. Responses contain configuration, scope, status, key-version, and validation metadata but never stored values. Access-event rows contain identifiers, binding keys, capabilities, outcomes, and denial reasons; they never contain secret material. Management inputs are validated at runtime rather than trusted from TypeScript types. Create requests must explicitly declare kind, scope, capabilities, and an allowlist (an empty array for project credentials). Names, kinds, binding keys, project ids, capabilities, and list counts are bounded; malformed arrays, unknown mutation fields, and control characters are rejected instead of being silently coerced. A stored value is limited to 64 KiB of UTF-8 data. Global allowlists must explicitly retain the management owner. diff --git a/src/contracts/custom-node-types.ts b/src/contracts/custom-node-types.ts index de8c742b19..0ad17654bf 100644 --- a/src/contracts/custom-node-types.ts +++ b/src/contracts/custom-node-types.ts @@ -218,7 +218,10 @@ export function customNodeDefinitionFromArtifact(artifact: CustomNodeArtifact): slot: slot.slot, label: slot.label, required: slot.required, - allowedKinds: [...slot.allowedKinds], + allowedKinds: [...new Set(slot.allowedKinds.map((kind) => kind.trim()))], + // Published custom-node schema v1 manifests use the singular field. Carry it + // into the node-definition slot policy instead of applying a flow-level default. + requiredCapabilities: [slot.requiredCapability.trim()], })), capabilities: [...manifest.capabilities], sideEffect: manifest.capabilities.includes("network.http") ? "external" : "none", diff --git a/src/contracts/node-definition-types.ts b/src/contracts/node-definition-types.ts index 9638fa2d0a..ce21984c2d 100644 --- a/src/contracts/node-definition-types.ts +++ b/src/contracts/node-definition-types.ts @@ -13,6 +13,7 @@ export interface NodeDefinitionCredentialRequirement { label: string; required: boolean; allowedKinds: string[]; + requiredCapabilities: string[]; } export interface NodeDefinitionUiManifest { diff --git a/src/contracts/node-flow-types.ts b/src/contracts/node-flow-types.ts index 60f9d55452..e32e6f2c4d 100644 --- a/src/contracts/node-flow-types.ts +++ b/src/contracts/node-flow-types.ts @@ -1,3 +1,5 @@ +import type { AutomationCredentialCompatibilityIssue } from "./automation-credential-types.js"; + export type NodeWidgetFieldType = | "text" | "textarea" @@ -244,6 +246,21 @@ export interface NodeFlowRequiredCredential { required: boolean; credentialId: string | null; status: "bound" | "missing" | "denied"; + backendReady: boolean | null; + configured: boolean | null; + active: boolean | null; + projectAccess: boolean | null; + kindAllowed: boolean | null; + capabilitiesAllowed: boolean | null; + missingCapabilities: string[]; + compatibilityIssues: AutomationCredentialCompatibilityIssue[]; +} + +export interface NodeFlowCredentialRequestResult extends NodeFlowRequiredCredential { + requestStatus: "already_bound" | "requested"; + /** This compatibility endpoint never writes or replaces a graph credential binding. */ + persistence: "none"; + bindingChanged: false; } export interface NodeFlowDraftReview { diff --git a/src/domain/node-flows/node-definition-registry.ts b/src/domain/node-flows/node-definition-registry.ts index 02f0a9c0a1..a3927805d4 100644 --- a/src/domain/node-flows/node-definition-registry.ts +++ b/src/domain/node-flows/node-definition-registry.ts @@ -1,6 +1,10 @@ -import type { NodeDefinitionManifest } from "../../contracts/node-definition-types.js"; +import type { NodeDefinitionCredentialRequirement, NodeDefinitionManifest } from "../../contracts/node-definition-types.js"; import type { NodeFlowPort, NodeFlowValueSchema, NodeWidgetField } from "../../contracts/node-flow-types.js"; +const MAX_CREDENTIAL_POLICY_ITEMS = 128; +const MAX_CREDENTIAL_POLICY_VALUE_LENGTH = 128; +const CREDENTIAL_POLICY_VALUE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/; + const objectSchema = (required: string[] = [], properties: Record = {}) => ({ type: "object" as const, ...(required.length > 0 ? { required } : {}), @@ -27,6 +31,7 @@ const builtin = (input: { properties?: Record; fields?: NodeWidgetField[]; ports?: NodeFlowPort[]; sideEffect?: NodeDefinitionManifest["sideEffect"]; capabilities?: string[]; + credentials: NodeDefinitionCredentialRequirement[]; }): NodeDefinitionManifest => ({ type: input.type, version: 1, @@ -35,7 +40,7 @@ const builtin = (input: { configurationSchema: objectSchema([], input.properties), ui: { label: input.label, description: input.description, category: input.category, widgetSchema: { fields: input.fields ?? [] } }, ports: input.ports ?? [dataPort("input", "input"), dataPort("output", "output")], - credentials: [], + credentials: input.credentials, capabilities: input.capabilities ?? [], sideEffect: input.sideEffect ?? "none", defaultPolicy: { retry: { maxAttempts: 1, backoffMs: 0 } }, @@ -73,7 +78,7 @@ const manifests: NodeDefinitionManifest[] = [ configurationSchema: objectSchema(["prompt"], { prompt: { type: "string" }, template: { type: "string" }, provider: { type: "string" } }), ui: { label: "Provider prompt", description: "Runs a prompt through a configured CLI provider.", category: "ai", widgetSchema: { fields: [field("prompt", "Prompt", "textarea", true), field("provider", "Provider", "text")] } }, ports: [dataPort("input", "input"), dataPort("output", "output")], - credentials: [{ slot: "provider", label: "Provider connection", required: false, allowedKinds: ["provider"] }], + credentials: [{ slot: "provider", label: "Provider connection", required: false, allowedKinds: ["provider"], requiredCapabilities: ["read"] }], capabilities: ["provider.execute"], sideEffect: "external", defaultPolicy: { retry: { maxAttempts: 1, backoffMs: 0 } }, documentation: "docs/architecture/node-flows.md#runtime", deprecation: { deprecated: false }, @@ -83,40 +88,50 @@ const manifests: NodeDefinitionManifest[] = [ configurationSchema: objectSchema(["url"], { url: { type: "string" }, method: { type: "string" }, timeoutMs: { type: "number" }, headers: { type: "object" } }), ui: { label: "HTTP request", description: "Calls a bounded HTTP or HTTPS endpoint.", category: "integration", widgetSchema: { fields: [field("url", "URL", "text", true), field("method", "Method", "text")] } }, ports: [dataPort("input", "input"), dataPort("output", "output")], - credentials: [{ slot: "auth", label: "HTTP credential", required: false, allowedKinds: ["http"] }], + credentials: [{ slot: "auth", label: "HTTP credential", required: false, allowedKinds: ["http"], requiredCapabilities: ["read"] }], capabilities: ["network.http"], sideEffect: "external", defaultPolicy: { retry: { maxAttempts: 1, backoffMs: 0 }, timeout: { timeoutMs: 30_000 } }, documentation: "docs/architecture/node-flows.md#runtime", deprecation: { deprecated: false }, }, builtin({ type: "condition", label: "Condition", description: "Selects one explicit boolean branch.", category: "control", + credentials: [], properties: { path: { type: "string" }, operator: { type: "string" }, value: { type: "any" } }, fields: [field("path", "Value path", "text"), field("operator", "Operator", "select")], ports: [dataPort("input", "input"), dataPort("true", "output"), dataPort("false", "output")] }), builtin({ type: "switch", label: "Switch", description: "Selects one named case or the default branch.", category: "control", + credentials: [], properties: { path: { type: "string" }, cases: { type: "array", items: { type: "object" } } }, fields: [field("path", "Value path", "text"), field("cases", "Cases", "json")], ports: [dataPort("input", "input"), { ...dataPort("case", "output"), cardinality: "many" }, dataPort("default", "output")] }), builtin({ type: "foreach", label: "Foreach", description: "Emits a bounded list for deterministic fan-out.", category: "control", + credentials: [], properties: { path: { type: "string" }, maxItems: { type: "number" }, concurrency: { type: "number" } }, fields: [field("path", "Items path", "text"), field("maxItems", "Maximum items", "number"), field("concurrency", "Concurrency", "number")], ports: [dataPort("input", "input"), { ...dataPort("items", "output"), schema: { type: "array", items: { type: "any" } } }, dataPort("empty", "output")] }), builtin({ type: "merge", label: "Merge", description: "Combines upstream values with an explicit strategy.", category: "transform", + credentials: [], properties: { strategy: { type: "string" } }, fields: [field("strategy", "Strategy", "select")], ports: [{ ...dataPort("input", "input"), cardinality: "many" }, dataPort("output", "output")] }), builtin({ type: "delay", label: "Delay", description: "Waits for a bounded duration with cancellation.", category: "control", + credentials: [], properties: { delayMs: { type: "number" } }, fields: [field("delayMs", "Delay (ms)", "number", true)] }), builtin({ type: "approval", label: "Approval", description: "Persists an operator decision gate.", category: "control", + credentials: [], properties: { summary: { type: "string" }, logicalItem: { type: "string" } }, fields: [field("summary", "Summary", "textarea")], ports: [dataPort("input", "input"), dataPort("approved", "output"), dataPort("rejected", "output")], sideEffect: "write" }), builtin({ type: "email_draft", label: "Email Draft", description: "Creates an email draft without sending it.", category: "integration", + credentials: [], properties: { to: { type: "any" }, subject: { type: "string" }, body: { type: "string" } }, fields: [field("to", "To", "text", true), field("subject", "Subject", "text", true), field("body", "Body", "textarea", true)] }), builtin({ type: "email_send", label: "Email Send", description: "Sends an approved email through the idempotent outbox.", category: "integration", + credentials: [], properties: { to: { type: "any" }, subject: { type: "string" }, body: { type: "string" }, logicalItem: { type: "string" } }, fields: [field("to", "To", "text", true), field("subject", "Subject", "text", true), field("body", "Body", "textarea", true)], sideEffect: "external", capabilities: ["email.send"] }), builtin({ type: "execute_subflow", label: "Execute Subflow", description: "Executes a project-owned published flow.", category: "control", + credentials: [], properties: { flowId: { type: "string" }, input: { type: "object" } }, fields: [field("flowId", "Flow ID", "text", true), field("input", "Input", "json")] }), builtin({ type: "webhook_trigger", label: "Webhook Trigger", description: "Emits authenticated webhook input.", category: "trigger", + credentials: [], ports: [dataPort("output", "output")], capabilities: ["webhook.receive"] }), { type: "output", version: 1, executable: true, executionKind: "local", @@ -128,6 +143,37 @@ const manifests: NodeDefinitionManifest[] = [ }, ]; +export function validateNodeDefinitionCredentialPolicy(manifest: NodeDefinitionManifest): string[] { + const issues: string[] = []; + if (manifest.credentials.length > MAX_CREDENTIAL_POLICY_ITEMS) { + issues.push(`Definition ${manifest.type}@${manifest.version} declares too many credential slots.`); + } + const slots = new Set(); + for (const requirement of manifest.credentials) { + if (!CREDENTIAL_POLICY_VALUE.test(requirement.slot) || requirement.slot.length > MAX_CREDENTIAL_POLICY_VALUE_LENGTH) { + issues.push(`Definition ${manifest.type}@${manifest.version} has an invalid credential slot.`); + } else if (slots.has(requirement.slot)) { + issues.push(`Definition ${manifest.type}@${manifest.version} has a duplicate credential slot: ${requirement.slot}.`); + } + slots.add(requirement.slot); + for (const [label, values] of [ + ["allowed kinds", requirement.allowedKinds], + ["required capabilities", requirement.requiredCapabilities], + ] as const) { + if (!Array.isArray(values) || values.length === 0 || values.length > MAX_CREDENTIAL_POLICY_ITEMS + || values.some((value) => typeof value !== "string" || value.length > MAX_CREDENTIAL_POLICY_VALUE_LENGTH || !CREDENTIAL_POLICY_VALUE.test(value))) { + issues.push(`Definition ${manifest.type}@${manifest.version} must declare bounded ${label} for slot ${requirement.slot}.`); + } + } + } + return issues; +} + +for (const manifest of manifests) { + const [policyIssue] = validateNodeDefinitionCredentialPolicy(manifest); + if (policyIssue) throw new Error(policyIssue); +} + const keyFor = (type: string, version: number): string => `${type}@${version}`; const registry = new Map(manifests.map((manifest) => [keyFor(manifest.type, manifest.version), manifest])); @@ -145,6 +191,8 @@ export const registerCustomNodeDefinition = (manifest: NodeDefinitionManifest): if (manifest.executionKind !== "custom" || !manifest.type.startsWith("custom.") || manifest.executable !== true) { throw new Error("Only executable custom node definitions can be registered dynamically."); } + const [policyIssue] = validateNodeDefinitionCredentialPolicy(manifest); + if (policyIssue) throw new Error(policyIssue); const key = keyFor(manifest.type, manifest.version); const existing = registry.get(key); if (existing) { diff --git a/src/domain/node-flows/node-flow-validation.ts b/src/domain/node-flows/node-flow-validation.ts index a7407b3a2f..ca69bb99d7 100644 --- a/src/domain/node-flows/node-flow-validation.ts +++ b/src/domain/node-flows/node-flow-validation.ts @@ -18,7 +18,7 @@ import type { } from "../../contracts/node-flow-types.js"; import { NODE_FLOW_SCHEMA_VERSION } from "../../contracts/node-flow-types.js"; import { migrateNodeFlowGraph } from "./node-flow-migrators.js"; -import { resolveNodeDefinition } from "./node-definition-registry.js"; +import { resolveNodeDefinition, validateNodeDefinitionCredentialPolicy } from "./node-definition-registry.js"; const MAX_GRAPH_NODES = 250; const MAX_GRAPH_EDGES = 1_000; @@ -631,6 +631,11 @@ function normalizeNode(rawNode: unknown, index: number, issues: NodeFlowValidati if (validDefinitionRef && !definition) { issues.push(issue(`${nodePath}.definition`, "unknown_node_definition", `Unknown node definition: ${definitionRef.type}@${definitionRef.version}`)); } + if (definition) { + for (const message of validateNodeDefinitionCredentialPolicy(definition)) { + issues.push(issue(`${nodePath}.definition`, "invalid_credential_policy", message)); + } + } if (definition && rawNode.sideEffect !== undefined && rawNode.sideEffect !== definition.sideEffect) { issues.push(issue(`${nodePath}.sideEffect`, "definition_metadata_mismatch", "Node side effect must match its definition.")); } diff --git a/src/mcp/management/node-flow-actions.ts b/src/mcp/management/node-flow-actions.ts index f1b89e274f..11202478c6 100644 --- a/src/mcp/management/node-flow-actions.ts +++ b/src/mcp/management/node-flow-actions.ts @@ -41,29 +41,29 @@ export class NodeFlowActions { case "get_node_definition": return this.getNodeDefinition(payload); case "create_draft": - return this.createDraft(payload); + return await this.createDraft(payload); case "patch_draft": - return this.patchDraft(payload); + return await this.patchDraft(payload); case "validate_draft": - return this.validateDraft(payload); + return await this.validateDraft(payload); case "request_credential": - return this.requestCredential(payload); + return await this.requestCredential(payload); case "inspect_bindings": - return this.inspectBindings(payload); + return await this.inspectBindings(payload); case "dry_run": - return this.dryRun(payload); + return await this.dryRun(payload); case "publish": - return this.publishDraft(args, payload); + return await this.publishDraft(args, payload); case "compare_versions": return this.compareVersions(payload); case "rollback": - return this.rollback(args, payload); + return await this.rollback(args, payload); case "get": - return this.getFlow(payload); + return await this.getFlow(payload); case "create": - return this.createFlow(payload); + return await this.createFlow(payload); case "update": - return this.updateFlow(payload); + return await this.updateFlow(payload); case "delete": return this.deleteFlow(args, payload); case "validate": @@ -104,24 +104,24 @@ export class NodeFlowActions { return { result: { definition } }; } - private createDraft(payload: Record): ManagementResponseEnvelope { + private async createDraft(payload: Record): Promise { const graph = this.parseGraphWithWidgets(payload, true); if (!graph) throw managementValidationError("graph object is required", "graph"); const validation = this.nodeFlowService.validate(graph); if (!validation.valid || !validation.graph) return { result: { status: "invalid", validationIssues: validation.errors } }; - return { result: { draft: this.nodeFlowService.createDraft(parseRequiredString(payload, "projectId"), { + return { result: { draft: await this.nodeFlowService.createDraft(parseRequiredString(payload, "projectId"), { title: parseRequiredString(payload, "name"), description: parseOptionalText(payload, "description"), graph: validation.graph, }) } }; } - private patchDraft(payload: Record): ManagementResponseEnvelope { + private async patchDraft(payload: Record): Promise { const projectId = parseRequiredString(payload, "projectId"); const flowId = parseRequiredString(payload, "flowId"); const draftRevision = requiredInteger(payload, "draftRevision"); const patch = parseOptionalObject>(payload, "patch") ?? {}; const graph = this.parseGraphWithWidgets(patch, false) ?? this.parseGraphWithWidgets(payload, false); const operations = Array.isArray(patch.operations) ? patch.operations : Array.isArray(payload.operations) ? payload.operations : undefined; - return { result: this.nodeFlowService.patchDraft(flowId, { + return { result: await this.nodeFlowService.patchDraft(flowId, { projectId, draftRevision, graph, operations: operations as import("../../contracts/node-flow-types.js").NodeFlowGraphPatchOperation[] | undefined, title: parseOptionalString(patch, "name") ?? parseOptionalString(payload, "name"), @@ -129,38 +129,38 @@ export class NodeFlowActions { }) }; } - private validateDraft(payload: Record): ManagementResponseEnvelope { - return { result: { draft: this.nodeFlowService.validateDraft(parseRequiredString(payload, "projectId"), parseRequiredString(payload, "flowId")) } }; + private async validateDraft(payload: Record): Promise { + return { result: { draft: await this.nodeFlowService.validateDraft(parseRequiredString(payload, "projectId"), parseRequiredString(payload, "flowId")) } }; } - private requestCredential(payload: Record): ManagementResponseEnvelope { - return { result: { request: this.nodeFlowService.requestCredential(parseRequiredString(payload, "projectId"), parseRequiredString(payload, "flowId"), parseRequiredString(payload, "nodeId"), parseRequiredString(payload, "slot")) } }; + private async requestCredential(payload: Record): Promise { + return { result: { request: await this.nodeFlowService.requestCredential(parseRequiredString(payload, "projectId"), parseRequiredString(payload, "flowId"), parseRequiredString(payload, "nodeId"), parseRequiredString(payload, "slot")) } }; } - private inspectBindings(payload: Record): ManagementResponseEnvelope { - return { result: this.nodeFlowService.inspectBindings(parseRequiredString(payload, "projectId"), parseRequiredString(payload, "flowId")) }; + private async inspectBindings(payload: Record): Promise { + return { result: await this.nodeFlowService.inspectBindings(parseRequiredString(payload, "projectId"), parseRequiredString(payload, "flowId")) }; } - private dryRun(payload: Record): ManagementResponseEnvelope { - return { result: this.nodeFlowService.dryRun(parseRequiredString(payload, "projectId"), parseRequiredString(payload, "flowId"), parseOptionalObject(payload, "input") ?? {}) }; + private async dryRun(payload: Record): Promise { + return { result: await this.nodeFlowService.dryRun(parseRequiredString(payload, "projectId"), parseRequiredString(payload, "flowId"), parseOptionalObject(payload, "input") ?? {}) }; } - private publishDraft(args: ManageCodeUxArgs, payload: Record): ManagementResponseEnvelope { + private async publishDraft(args: ManageCodeUxArgs, payload: Record): Promise { const flowId = parseRequiredString(payload, "flowId"); const draftRevision = requiredInteger(payload, "draftRevision"); if (args.approval?.confirmed !== true) return { approvalRequired: true, approvalMessage: `Publish node flow ${flowId} draft revision ${draftRevision} after reviewing validation, credentials, capabilities, and side effects.` }; - return { result: { draft: this.nodeFlowService.publishDraft(parseRequiredString(payload, "projectId"), flowId, draftRevision, parseOptionalString(payload, "publishedBy") ?? "project-manager-mcp") } }; + return { result: { draft: await this.nodeFlowService.publishDraft(parseRequiredString(payload, "projectId"), flowId, draftRevision, parseOptionalString(payload, "publishedBy") ?? "project-manager-mcp") } }; } private compareVersions(payload: Record): ManagementResponseEnvelope { return { result: this.nodeFlowService.compareVersions(parseRequiredString(payload, "projectId"), parseRequiredString(payload, "flowId"), requiredInteger(payload, "fromVersion"), requiredInteger(payload, "toVersion")) }; } - private rollback(args: ManageCodeUxArgs, payload: Record): ManagementResponseEnvelope { + private async rollback(args: ManageCodeUxArgs, payload: Record): Promise { const flowId = parseRequiredString(payload, "flowId"); const version = requiredInteger(payload, "version"); if (args.approval?.confirmed !== true) return { approvalRequired: true, approvalMessage: `Create a new draft of node flow ${flowId} from version ${version}. The current draft remains in immutable history.` }; - return { result: { draft: this.nodeFlowService.rollback(parseRequiredString(payload, "projectId"), flowId, version, requiredInteger(payload, "draftRevision")) } }; + return { result: { draft: await this.nodeFlowService.rollback(parseRequiredString(payload, "projectId"), flowId, version, requiredInteger(payload, "draftRevision")) } }; } private cancelRun(payload: Record): ManagementResponseEnvelope { @@ -203,20 +203,20 @@ export class NodeFlowActions { return { result: { flows } }; } - private getFlow(payload: Record): ManagementResponseEnvelope { + private async getFlow(payload: Record): Promise { const flowId = parseRequiredString(payload, "flowId"); const flow = this.requireFlow(flowId); this.assertProjectMatch(payload, flow); return { result: { flow: formatFlowForCaller(flow), - ...(getCurrentMcpAgentId() ? { draft: this.nodeFlowService.validateDraft(flow.projectId, flow.id) } : {}), + ...(getCurrentMcpAgentId() ? { draft: await this.nodeFlowService.validateDraft(flow.projectId, flow.id) } : {}), agentSkills: this.nodeFlowService.listAgentSkills(flow.id), }, }; } - private createFlow(payload: Record): ManagementResponseEnvelope { + private async createFlow(payload: Record): Promise { const projectId = parseRequiredString(payload, "projectId"); const graph = this.parseGraphWithWidgets(payload, true); if (!graph) { @@ -227,7 +227,7 @@ export class NodeFlowActions { throw validationToManagementError(validation.errors); } - const flow = this.nodeFlowService.create(projectId, { + const flow = await this.nodeFlowService.create(projectId, { title: parseRequiredString(payload, "name"), description: parseOptionalText(payload, "description"), graph: validation.graph, @@ -235,7 +235,7 @@ export class NodeFlowActions { return { result: { flow: formatFlowForCaller(flow) } }; } - private updateFlow(payload: Record): ManagementResponseEnvelope { + private async updateFlow(payload: Record): Promise { const flowId = parseRequiredString(payload, "flowId"); const graph = this.parseGraphWithWidgets(payload, false); const validation = graph ? this.nodeFlowService.validate(graph) : null; @@ -245,7 +245,7 @@ export class NodeFlowActions { const name = parseOptionalString(payload, "name"); const description = parseOptionalText(payload, "description"); - const flow = this.nodeFlowService.update(flowId, { + const flow = await this.nodeFlowService.update(flowId, { ...(name !== undefined ? { title: name } : {}), ...(description !== undefined ? { description } : {}), ...(validation?.graph ? { graph: validation.graph } : {}), diff --git a/src/server/node-flow-routes.ts b/src/server/node-flow-routes.ts index b5ad71cda4..fb54b38ae6 100644 --- a/src/server/node-flow-routes.ts +++ b/src/server/node-flow-routes.ts @@ -32,39 +32,39 @@ export function registerNodeFlowRoutes(app: Express, deps: DashboardDependencies res.json(definition); })); - app.post("/api/projects/:projectId/node-flow-drafts", syncRoute((req, res) => { - res.status(201).json(requireNodeFlowService(deps).createDraft( + app.post("/api/projects/:projectId/node-flow-drafts", asyncRoute(async (req, res) => { + res.status(201).json(await requireNodeFlowService(deps).createDraft( requireTrimmedString(req.params.projectId, "projectId"), req.body as CreateNodeFlowInput, )); })); - app.patch("/api/node-flow-drafts/:flowId", syncRoute((req, res) => { - const result = requireNodeFlowService(deps).patchDraft( + app.patch("/api/node-flow-drafts/:flowId", asyncRoute(async (req, res) => { + const result = await requireNodeFlowService(deps).patchDraft( requireTrimmedString(req.params.flowId, "flowId"), req.body, ); res.status(result.conflict ? 409 : 200).json(result); })); - app.post("/api/node-flow-drafts/:flowId/validate", syncRoute((req, res) => { - res.json(requireNodeFlowService(deps).validateDraft( + app.post("/api/node-flow-drafts/:flowId/validate", asyncRoute(async (req, res) => { + res.json(await requireNodeFlowService(deps).validateDraft( requireTrimmedString(req.body?.projectId, "projectId"), requireTrimmedString(req.params.flowId, "flowId"), )); })); - app.post("/api/node-flow-drafts/:flowId/dry-run", syncRoute((req, res) => { - res.json(requireNodeFlowService(deps).dryRun( + app.post("/api/node-flow-drafts/:flowId/dry-run", asyncRoute(async (req, res) => { + res.json(await requireNodeFlowService(deps).dryRun( requireTrimmedString(req.body?.projectId, "projectId"), requireTrimmedString(req.params.flowId, "flowId"), req.body?.input ?? {}, )); })); - app.get("/api/node-flow-drafts/:flowId/bindings", syncRoute((req, res) => { - res.json(requireNodeFlowService(deps).inspectBindings( + app.get("/api/node-flow-drafts/:flowId/bindings", asyncRoute(async (req, res) => { + res.json(await requireNodeFlowService(deps).inspectBindings( requireTrimmedString(req.query.projectId, "projectId"), requireTrimmedString(req.params.flowId, "flowId"), )); })); - app.post("/api/node-flow-drafts/:flowId/credential-requests", syncRoute((req, res) => { - res.status(201).json(requireNodeFlowService(deps).requestCredential( + app.post("/api/node-flow-drafts/:flowId/credential-requests", asyncRoute(async (req, res) => { + res.status(201).json(await requireNodeFlowService(deps).requestCredential( requireTrimmedString(req.body?.projectId, "projectId"), requireTrimmedString(req.params.flowId, "flowId"), requireTrimmedString(req.body?.nodeId, "nodeId"), requireTrimmedString(req.body?.slot, "slot"), )); @@ -87,8 +87,8 @@ export function registerNodeFlowRoutes(app: Express, deps: DashboardDependencies )); })); - app.post("/api/node-flow-drafts/:flowId/publish", syncRoute((req, res) => { - res.json(requireNodeFlowService(deps).publishDraft( + app.post("/api/node-flow-drafts/:flowId/publish", asyncRoute(async (req, res) => { + res.json(await requireNodeFlowService(deps).publishDraft( requireTrimmedString(req.body?.projectId, "projectId"), requireTrimmedString(req.params.flowId, "flowId"), parseRequiredBodyInteger(req.body?.draftRevision, "draftRevision"), requireTrimmedString(req.body?.publishedBy, "publishedBy"), )); @@ -101,8 +101,8 @@ export function registerNodeFlowRoutes(app: Express, deps: DashboardDependencies )); })); - app.post("/api/node-flows/:flowId/rollback", syncRoute((req, res) => { - res.json(requireNodeFlowService(deps).rollback( + app.post("/api/node-flows/:flowId/rollback", asyncRoute(async (req, res) => { + res.json(await requireNodeFlowService(deps).rollback( requireTrimmedString(req.body?.projectId, "projectId"), requireTrimmedString(req.params.flowId, "flowId"), parseRequiredBodyInteger(req.body?.version, "version"), parseRequiredBodyInteger(req.body?.draftRevision, "draftRevision"), )); @@ -127,8 +127,8 @@ export function registerNodeFlowRoutes(app: Express, deps: DashboardDependencies res.json(requireNodeFlowService(deps).list(requireTrimmedString(req.params.projectId, "projectId"))); })); - app.post("/api/projects/:projectId/node-flows", syncRoute((req, res) => { - const flow = requireNodeFlowService(deps).create( + app.post("/api/projects/:projectId/node-flows", asyncRoute(async (req, res) => { + const flow = await requireNodeFlowService(deps).create( requireTrimmedString(req.params.projectId, "projectId"), req.body as CreateNodeFlowInput, ); @@ -144,8 +144,8 @@ export function registerNodeFlowRoutes(app: Express, deps: DashboardDependencies res.json(flow); })); - app.patch("/api/node-flows/:flowId", syncRoute((req, res) => { - res.json(requireNodeFlowService(deps).update( + app.patch("/api/node-flows/:flowId", asyncRoute(async (req, res) => { + res.json(await requireNodeFlowService(deps).update( requireTrimmedString(req.params.flowId, "flowId"), req.body as UpdateNodeFlowInput, )); diff --git a/src/services/node-flow-agent-skill-service.ts b/src/services/node-flow-agent-skill-service.ts index 16de46ccfa..a4d8bc5b81 100644 --- a/src/services/node-flow-agent-skill-service.ts +++ b/src/services/node-flow-agent-skill-service.ts @@ -39,7 +39,7 @@ export class NodeFlowAgentSkillService { const capability = this.listCapabilities(input.projectId, input.agentPresetId) .find((item) => item.flowId === input.flowId); if (!capability) throw new EntityNotFoundError("Node flow is not attached to the initiating agent."); - const review = this.nodeFlowService.validateDraft(input.projectId, input.flowId); + const review = await this.nodeFlowService.validateDraft(input.projectId, input.flowId); if (review.publishedVersion === null) throw new ValidationError("Attached node flow has not been published."); if (review.requiredCredentials.some((credential) => credential.status !== "bound")) { throw new ValidationError("Attached node flow credential policy is not satisfied."); diff --git a/src/services/node-flow-runtime-service.ts b/src/services/node-flow-runtime-service.ts index 08de57729a..021885ff84 100644 --- a/src/services/node-flow-runtime-service.ts +++ b/src/services/node-flow-runtime-service.ts @@ -40,6 +40,7 @@ import type { NodeFlowRunSummaryResponse, RunNodeFlowOptions, } from "../contracts/node-flow-types.js"; +import type { NodeDefinitionManifest } from "../contracts/node-definition-types.js"; const EXTERNALLY_OBSERVABLE_NODE_TYPES = new Set(["provider_prompt", "http_request"]); const CLI_PROVIDER_IDS = new Set(["gemini", "codex", "claude-code", "qwen-code", "opencode", "antigravity", "mockup-cli"]); @@ -718,6 +719,7 @@ export class NodeFlowRuntimeService { ): Promise { const reference = node.definition ?? { type: node.type, version: 1 }; const definition = resolveNodeDefinition(reference.type, reference.version); + this.validateNodeCredentialBindings(node, definition); if (EXTERNALLY_OBSERVABLE_NODE_TYPES.has(node.type) || definition?.executionKind === "custom") { const invocation = this.deps.executionRepository.createExecutionInvocation({ projectId: context.projectId, @@ -1043,13 +1045,42 @@ export class NodeFlowRuntimeService { } } - private async resolveNodeCredential(context: RuntimeContext,node:NodeFlowNode,slot:string):Promise{ - const binding=node.credentialBindings?.find((candidate)=>candidate.slot===slot); - if (!binding) return undefined; - if (!this.deps.credentialBroker) throw new ValidationError("Credential broker is not configured for node flow runtime."); - const requirement=node.definition?resolveNodeDefinition(node.definition.type,node.definition.version)?.credentials.find((candidate)=>candidate.slot===slot):undefined; + private validateNodeCredentialBindings(node: NodeFlowNode, definition: NodeDefinitionManifest | null): void { + if (!definition) throw new ValidationError(`Node ${node.id} references an unavailable definition.`); + const declaredSlots = new Map(definition.credentials.map((requirement) => [requirement.slot, requirement])); + const boundSlots = new Set(); + for (const binding of node.credentialBindings ?? []) { + if (!declaredSlots.has(binding.slot)) { + throw new ValidationError(`Node ${node.id} does not declare credential slot ${binding.slot}.`); + } + if (boundSlots.has(binding.slot)) { + throw new ValidationError(`Node ${node.id} has more than one binding for credential slot ${binding.slot}.`); + } + boundSlots.add(binding.slot); + } + const missing = definition.credentials.find((requirement) => requirement.required && !boundSlots.has(requirement.slot)); + if (missing) throw new ValidationError(`Node ${node.id} requires credential slot ${missing.slot}.`); + } + + private async resolveNodeCredential(context: RuntimeContext, node: NodeFlowNode, slot: string): Promise { + const reference = node.definition ?? { type: node.type, version: 1 }; + const definition = resolveNodeDefinition(reference.type, reference.version); + const requirement = definition?.credentials.find((candidate) => candidate.slot === slot); if (!requirement) throw new ValidationError(`Node ${node.id} does not declare credential slot ${slot}.`); - const resolved=await this.deps.credentialBroker.resolveCredentialId({projectId:context.projectId,credentialId:binding.credentialId,bindingKey:`${context.flowId}:${node.id}:${slot}`,requiredCapabilities:["read"],allowedKinds:requirement.allowedKinds,workspaceId:context.runId}); + const binding = node.credentialBindings?.find((candidate) => candidate.slot === slot); + if (!binding) { + if (requirement.required) throw new ValidationError(`Node ${node.id} requires credential slot ${slot}.`); + return undefined; + } + if (!this.deps.credentialBroker) throw new ValidationError("Credential broker is not configured for node flow runtime."); + const resolved = await this.deps.credentialBroker.resolveCredentialId({ + projectId: context.projectId, + credentialId: binding.credentialId, + bindingKey: `${context.flowId}:${node.id}:${slot}`, + requiredCapabilities: requirement.requiredCapabilities, + allowedKinds: requirement.allowedKinds, + workspaceId: context.runId, + }); if (resolved.value) context.resolvedCredentialValues.push(resolved.value); return resolved.value; } diff --git a/src/services/node-flow-service.ts b/src/services/node-flow-service.ts index 09a4f4820f..8f37aa821e 100644 --- a/src/services/node-flow-service.ts +++ b/src/services/node-flow-service.ts @@ -12,6 +12,7 @@ import type { CreateNodeFlowInput, NodeFlowGraph, NodeFlowGraphPatchOperation, + NodeFlowCredentialRequestResult, NodeFlowJsonObject, NodeFlowListResponse, NodeFlowNodeRunListResponse, @@ -70,10 +71,21 @@ export class NodeFlowService { return this.repository.getRun(runId)?.projectId ?? null; } - create(projectId: string, input: CreateNodeFlowInput): NodeFlowRecord { + async create(projectId: string, input: CreateNodeFlowInput): Promise { const title = normalizeRequiredText(input.title, "Node flow title"); const description = normalizeOptionalText(input.description); const { graph } = normalizeNodeFlowGraph(input.graph); + const review = await this.reviewDraft({ + id: input.id?.trim() || "pending-node-flow", + projectId, + title, + description, + graph, + version: 1, + createdAt: "", + updatedAt: "", + }); + if (!review.valid) throw new ValidationError("Node flow credential policy must pass review before publication."); return this.repository.createFlow(projectId, { id: input.id, title, @@ -82,15 +94,15 @@ export class NodeFlowService { }); } - createDraft(projectId: string, input: CreateNodeFlowInput): NodeFlowDraftReview { + async createDraft(projectId: string, input: CreateNodeFlowInput): Promise { const title = normalizeRequiredText(input.title, "Node flow title"); const description = normalizeOptionalText(input.description); const { graph } = normalizeNodeFlowGraph(input.graph); const flow = this.repository.createFlow(projectId, { id: input.id, title, description, graph }, { publish: false }); - return this.reviewDraft(flow); + return await this.reviewDraft(flow); } - patchDraft(flowId: string, input: PatchNodeFlowDraftInput): { draft?: NodeFlowDraftReview; conflict?: NodeFlowConcurrencyConflict } { + async patchDraft(flowId: string, input: PatchNodeFlowDraftInput): Promise<{ draft?: NodeFlowDraftReview; conflict?: NodeFlowConcurrencyConflict }> { const current = this.requireOwnedFlow(flowId, input.projectId); if (current.version !== input.draftRevision) { return { conflict: { @@ -107,22 +119,22 @@ export class NodeFlowService { const graph = input.graph ?? applyGraphPatch(current.graph, input.operations ?? []); const validation = validateNodeFlowGraph(graph); if (!validation.valid || !validation.graph) { - return { draft: this.reviewDraft({ ...current, graph }, validation) }; + return { draft: await this.reviewDraft({ ...current, graph }, validation) }; } const updated = this.repository.updateFlow(flowId, { ...(input.title !== undefined ? { title: input.title } : {}), ...(input.description !== undefined ? { description: input.description } : {}), graph: validation.graph, }, { publish: false }); - return { draft: this.reviewDraft(updated, validation) }; + return { draft: await this.reviewDraft(updated, validation) }; } - validateDraft(projectId: string, flowId: string): NodeFlowDraftReview { - return this.reviewDraft(this.requireOwnedFlow(flowId, projectId)); + async validateDraft(projectId: string, flowId: string): Promise { + return await this.reviewDraft(this.requireOwnedFlow(flowId, projectId)); } - inspectBindings(projectId: string, flowId: string): Pick { - const review = this.reviewDraft(this.requireOwnedFlow(flowId, projectId)); + async inspectBindings(projectId: string, flowId: string): Promise> { + const review = await this.reviewDraft(this.requireOwnedFlow(flowId, projectId)); return { requiredCredentials: review.requiredCredentials, requestedCapabilities: review.requestedCapabilities, @@ -130,11 +142,16 @@ export class NodeFlowService { }; } - requestCredential(projectId: string, flowId: string, nodeId: string, slot: string): Record { - const review = this.reviewDraft(this.requireOwnedFlow(flowId, projectId)); + async requestCredential(projectId: string, flowId: string, nodeId: string, slot: string): Promise { + const review = await this.reviewDraft(this.requireOwnedFlow(flowId, projectId)); const requirement = review.requiredCredentials.find((item) => item.nodeId === nodeId && item.slot === slot); if (!requirement) throw new ValidationError(`Credential slot is not declared: ${nodeId}.${slot}`); - return { projectId, flowId, ...requirement, requestStatus: requirement.status === "bound" ? "already_bound" : "requested" }; + return { + ...requirement, + requestStatus: requirement.status === "bound" ? "already_bound" : "requested", + persistence: "none", + bindingChanged: false, + }; } async createCustomNode(projectId: string, input: { nodeId: string; name: string; description?: string; sourceRevision: string; createdBy: string }): Promise { @@ -163,10 +180,10 @@ export class NodeFlowService { return { nodeId, status: result.report.valid ? "passed" : "failed", validationIssues: result.report.issues, checks: result.report.checks, requestedCapabilities: current.manifest.capabilities, requiredCredentials: current.manifest.credentials }; } - dryRun(projectId: string, flowId: string, input: Record = {}): Record { + async dryRun(projectId: string, flowId: string, input: Record = {}): Promise> { normalizeJsonObject(input, "input"); - const review = this.reviewDraft(this.requireOwnedFlow(flowId, projectId)); - const missingCredentials = review.requiredCredentials.filter((item) => item.status !== "bound"); + const review = await this.reviewDraft(this.requireOwnedFlow(flowId, projectId)); + const missingCredentials = review.requiredCredentials.filter((item) => item.status === "denied" || (item.required && item.status === "missing")); return { status: review.valid && missingCredentials.length === 0 ? "ready" : "blocked", draftRevision: review.draftRevision, @@ -179,16 +196,16 @@ export class NodeFlowService { }; } - publishDraft(projectId: string, flowId: string, draftRevision: number, publishedBy: string): NodeFlowDraftReview { + async publishDraft(projectId: string, flowId: string, draftRevision: number, publishedBy: string): Promise { const flow = this.requireOwnedFlow(flowId, projectId); if (flow.version !== draftRevision) throw new ValidationError(`Draft revision conflict: expected ${draftRevision}, actual ${flow.version}.`); - const review = this.reviewDraft(flow); + const review = await this.reviewDraft(flow); if (!review.valid) throw new ValidationError("Only a valid draft can be published."); - if (review.requiredCredentials.some((item) => item.status !== "bound")) { + if (review.requiredCredentials.some((item) => item.status === "denied" || (item.required && item.status === "missing"))) { throw new ValidationError("All required credentials must be bound before publication."); } this.repository.publishVersion(flowId, draftRevision, undefined, normalizeRequiredText(publishedBy, "publishedBy")); - return this.reviewDraft(flow); + return await this.reviewDraft(flow); } compareVersions(projectId: string, flowId: string, fromVersion: number, toVersion: number): Record { @@ -206,12 +223,12 @@ export class NodeFlowService { }; } - rollback(projectId: string, flowId: string, version: number, draftRevision: number): NodeFlowDraftReview { + async rollback(projectId: string, flowId: string, version: number, draftRevision: number): Promise { const current = this.requireOwnedFlow(flowId, projectId); if (current.version !== draftRevision) throw new ValidationError(`Draft revision conflict: expected ${draftRevision}, actual ${current.version}.`); const target = this.repository.getVersion(flowId, version); if (!target) throw new EntityNotFoundError(`Node flow version not found: ${flowId}@${version}`); - return this.reviewDraft(this.repository.updateFlow(flowId, { + return await this.reviewDraft(this.repository.updateFlow(flowId, { title: target.title, description: target.description, graph: target.graph, }, { publish: false })); } @@ -239,7 +256,9 @@ export class NodeFlowService { return result; } - update(flowId: string, input: UpdateNodeFlowInput): NodeFlowRecord { + async update(flowId: string, input: UpdateNodeFlowInput): Promise { + const current = this.repository.getFlow(flowId); + if (!current) throw new EntityNotFoundError(`Node flow not found: ${flowId}`); const update: UpdateNodeFlowInput = {}; if (input.title !== undefined) { update.title = normalizeRequiredText(input.title, "Node flow title"); @@ -250,6 +269,14 @@ export class NodeFlowService { if (input.graph !== undefined) { update.graph = normalizeNodeFlowGraph(input.graph).graph; } + const review = await this.reviewDraft({ + ...current, + title: update.title ?? current.title, + description: update.description ?? current.description, + graph: update.graph ?? current.graph, + version: current.version + 1, + }); + if (!review.valid) throw new ValidationError("Node flow credential policy must pass review before publication."); return this.repository.updateFlow(flowId, update); } @@ -336,21 +363,67 @@ export class NodeFlowService { return run; } - private reviewDraft(flow: NodeFlowRecord, validation = validateNodeFlowGraph(flow.graph)): NodeFlowDraftReview { - const credentialMetadata = this.credentialBroker?.list(flow.projectId) ?? []; - const requiredCredentials: NodeFlowRequiredCredential[] = flow.graph.nodes.flatMap((node) => { + private async reviewDraft(flow: NodeFlowRecord, validation = validateNodeFlowGraph(flow.graph)): Promise { + const credentialRequirements = flow.graph.nodes.flatMap((node) => { const definition = node.definition ? resolveNodeDefinition(node.definition.type, node.definition.version) : undefined; - return (definition?.credentials ?? []).filter((slot) => slot.required || node.credentialBindings?.some((binding) => binding.slot === slot.slot)).map((slot) => { - const credentialId = node.credentialBindings?.find((binding) => binding.slot === slot.slot)?.credentialId ?? null; - const credential = credentialMetadata.find((item) => item.id === credentialId); - const requiredCapabilities = ["read"]; - const allowed = credential && credential.status === "active" - && slot.allowedKinds.includes(credential.kind) - && requiredCapabilities.every((capability) => credential.capabilities.includes(capability)); - const status: NodeFlowRequiredCredential["status"] = credentialId ? (allowed ? "bound" : "denied") : "missing"; - return { nodeId: node.id, slot: slot.slot, allowedKinds: [...slot.allowedKinds], requiredCapabilities, required: slot.required, credentialId, status }; - }); + return (definition?.credentials ?? []).map((slot) => ({ node, slot })); }); + const requiredCredentials: NodeFlowRequiredCredential[] = await Promise.all(credentialRequirements.map(async ({ node, slot }) => { + const credentialId = node.credentialBindings?.find((binding) => binding.slot === slot.slot)?.credentialId ?? null; + const policy = { + nodeId: node.id, + slot: slot.slot, + allowedKinds: [...slot.allowedKinds], + requiredCapabilities: [...slot.requiredCapabilities], + required: slot.required, + credentialId, + }; + if (!credentialId) { + return { + ...policy, + status: "missing" as const, + backendReady: null, + configured: null, + active: null, + projectAccess: null, + kindAllowed: null, + capabilitiesAllowed: null, + missingCapabilities: [...slot.requiredCapabilities], + compatibilityIssues: [], + }; + } + if (!this.credentialBroker) { + return { + ...policy, + status: "denied" as const, + backendReady: false, + configured: null, + active: null, + projectAccess: null, + kindAllowed: null, + capabilitiesAllowed: null, + missingCapabilities: [...slot.requiredCapabilities], + compatibilityIssues: ["backend_unavailable" as const], + }; + } + const assessment = await this.credentialBroker.assessCompatibility(credentialId, { + projectId: flow.projectId, + allowedKinds: slot.allowedKinds, + requiredCapabilities: slot.requiredCapabilities, + }); + return { + ...policy, + status: assessment.compatible ? "bound" as const : "denied" as const, + backendReady: assessment.backendReady, + configured: assessment.configured, + active: assessment.active, + projectAccess: assessment.projectAccess, + kindAllowed: assessment.kindAllowed, + capabilitiesAllowed: assessment.capabilitiesAllowed, + missingCapabilities: [...assessment.missingCapabilities], + compatibilityIssues: [...assessment.issues], + }; + })); const requestedCapabilities = [...new Set(flow.graph.nodes.flatMap((node) => node.capabilities ?? []))].sort(); const policyFindings = reviewPolicy(flow.graph, requiredCredentials); return { @@ -421,10 +494,33 @@ function reviewSideEffects(graph: NodeFlowGraph): NodeFlowDraftReview["sideEffec } function reviewPolicy(graph: NodeFlowGraph, credentials: NodeFlowDraftReview["requiredCredentials"]): NodeFlowDraftReview["policyFindings"] { - const findings: NodeFlowDraftReview["policyFindings"] = credentials.filter((item) => item.status === "denied" || (item.required && item.status === "missing")).map((item) => ({ - severity: "error", code: item.status === "missing" ? "missing_credential" : "credential_permission_denied", nodeId: item.nodeId, - message: `${item.nodeId}.${item.slot} requires an approved credential binding.`, - })); + const compatibilityFinding = { + backend_unavailable: ["credential_backend_unavailable", "The credential encryption backend is unavailable."], + backend_insecure: ["credential_backend_insecure", "The credential encryption backend is not secure."], + not_configured: ["credential_not_configured", "The bound credential is not configured."], + not_active: ["credential_not_active", "The bound credential is not active."], + project_access_denied: ["credential_project_access_denied", "The bound credential is not accessible to this project."], + kind_not_allowed: ["credential_kind_not_allowed", "The bound credential kind is not allowed for this slot."], + capability_missing: ["credential_capability_missing", "The bound credential does not grant every required capability."], + } as const; + const findings: NodeFlowDraftReview["policyFindings"] = credentials.flatMap((item) => { + if (item.required && item.status === "missing") { + return [{ + severity: "error" as const, + code: "missing_credential_binding", + nodeId: item.nodeId, + message: `${item.nodeId}.${item.slot} requires a stored credential binding.`, + }]; + } + if (item.status !== "denied") return []; + const issues = item.compatibilityIssues.length > 0 ? item.compatibilityIssues : ["not_configured" as const]; + return issues.map((compatibilityIssue) => ({ + severity: "error" as const, + code: compatibilityFinding[compatibilityIssue][0], + nodeId: item.nodeId, + message: `${item.nodeId}.${item.slot}: ${compatibilityFinding[compatibilityIssue][1]}`, + })); + }); for (const node of graph.nodes) { const definition = node.definition ? resolveNodeDefinition(node.definition.type, node.definition.version) : undefined; if ((node.sideEffect ?? definition?.sideEffect) === "external") findings.push({ severity: "warning", code: "external_side_effect", nodeId: node.id, message: "External side effects require publication review." }); diff --git a/tests/backend/domain/node-flows/node-flow-validation.test.ts b/tests/backend/domain/node-flows/node-flow-validation.test.ts index 0c4d681008..90991acfcd 100644 --- a/tests/backend/domain/node-flows/node-flow-validation.test.ts +++ b/tests/backend/domain/node-flows/node-flow-validation.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { normalizeNodeFlowGraph, validateNodeFlowGraph } from "../../../../src/domain/node-flows/node-flow-validation.js"; import type { NodeFlowGraph } from "../../../../src/contracts/node-flow-types.js"; +import { resolveNodeDefinition } from "../../../../src/domain/node-flows/node-definition-registry.js"; const validGraph = (): NodeFlowGraph => ({ inputSchema: { @@ -187,6 +188,41 @@ describe("node flow validation", () => { ])); }); + it("validates credential bindings against the declared slot policy", () => { + const graph: NodeFlowGraph = { + nodes: [{ + id: "request", + type: "http_request", + title: "Request", + data: { url: "https://example.test" }, + credentialBindings: [{ slot: "undeclared", credentialId: "credential-1" }], + }], + edges: [], + }; + + expect(validateNodeFlowGraph(graph).errors).toContainEqual(expect.objectContaining({ + field: "nodes[0].credentialBindings[0].slot", + code: "unknown_credential_slot", + })); + }); + + it("fails closed when a resolved definition exposes an unbounded credential policy", () => { + const definition = resolveNodeDefinition("http_request", 1)!; + const original = definition.credentials[0]!.requiredCapabilities; + definition.credentials[0]!.requiredCapabilities = []; + try { + expect(validateNodeFlowGraph({ + nodes: [{ id: "request", type: "http_request", title: "Request", data: { url: "https://example.test" } }], + edges: [], + }).errors).toContainEqual(expect.objectContaining({ + field: "nodes[0].definition", + code: "invalid_credential_policy", + })); + } finally { + definition.credentials[0]!.requiredCapabilities = original; + } + }); + it.each([ [{ schemaVersion: 2, nodes: null, edges: [] }, "nodes"], [{ nodes: [], edges: "invalid" }, "edges"], diff --git a/tests/backend/services/node-flow-builtins.test.ts b/tests/backend/services/node-flow-builtins.test.ts index 4e3992094d..3410f55147 100644 --- a/tests/backend/services/node-flow-builtins.test.ts +++ b/tests/backend/services/node-flow-builtins.test.ts @@ -1,9 +1,45 @@ import { describe, expect, it } from "vitest"; import { BuiltinExecutors, MAX_FOREACH_ITEMS } from "../../../src/services/node-flows/builtins/builtin-executors.js"; +import { listNodeDefinitions } from "../../../src/domain/node-flows/node-definition-registry.js"; +import { customNodeDefinitionFromArtifact, type CustomNodeArtifact } from "../../../src/contracts/custom-node-types.js"; const base = { projectId: "p", flowId: "f", publicationId: "pub", runId: "r", nodeId: "n", upstream: {}, flowInput: {}, subflowDepth: 0 }; describe("governed built-in executors", () => { + it("declares bounded credential kind and capability policy on every built-in slot", () => { + const requirements = listNodeDefinitions().filter((definition) => !definition.type.startsWith("custom.")) + .flatMap((definition) => definition.credentials.map((credential) => ({ definition: definition.type, credential }))); + + expect(requirements).toEqual(expect.arrayContaining([ + expect.objectContaining({ definition: "provider_prompt", credential: expect.objectContaining({ requiredCapabilities: ["read"] }) }), + expect.objectContaining({ definition: "http_request", credential: expect.objectContaining({ requiredCapabilities: ["read"] }) }), + ])); + expect(requirements.every(({ credential }) => credential.allowedKinds.length > 0 && credential.allowedKinds.length <= 128 + && credential.requiredCapabilities.length > 0 && credential.requiredCapabilities.length <= 128)).toBe(true); + }); + + it("normalizes the schema-v1 custom-node requiredCapability into definition policy", () => { + const artifact = { + manifest: { + nodeType: "custom.capability-fixture", + version: 1, + name: "Capability fixture", + description: "", + inputSchema: { type: "object" }, + outputSchema: { type: "object" }, + configurationSchema: { type: "object" }, + capabilities: ["credentials.read"], + credentials: [{ slot: "jobs", label: "Jobs", required: true, allowedKinds: ["http.token"], requiredCapability: "jobs.list" }], + resources: { timeoutMs: 30_000 }, + }, + } as unknown as CustomNodeArtifact; + + expect(customNodeDefinitionFromArtifact(artifact).credentials).toEqual([expect.objectContaining({ + slot: "jobs", + requiredCapabilities: ["jobs.list"], + })]); + }); + it("selects explicit condition and switch ports", async () => { const executors = new BuiltinExecutors(); await expect(executors.execute("condition", { ...base, flowInput: { enabled: true }, config: { path: "input.enabled" } })) diff --git a/tests/backend/services/node-flow-runtime-service.test.ts b/tests/backend/services/node-flow-runtime-service.test.ts index 7c48f1417f..4e25fd6d85 100644 --- a/tests/backend/services/node-flow-runtime-service.test.ts +++ b/tests/backend/services/node-flow-runtime-service.test.ts @@ -18,6 +18,7 @@ import { AutomationOutboxRepository } from "../../../src/repositories/automation import { ApprovalService } from "../../../src/services/node-flows/approval-service.js"; import { MockSideEffectProvider, OutboxService, type SideEffectProvider } from "../../../src/services/node-flows/outbox-service.js"; import { AutomationAuditExportService } from "../../../src/services/automation-audit-export-service.js"; +import { resolveNodeDefinition } from "../../../src/domain/node-flows/node-definition-registry.js"; const tempDirs: string[] = []; @@ -299,6 +300,99 @@ describe("NodeFlowRuntimeService", () => { expect(executionRepository.getExecutionInvocation(promptRun!.executionInvocationId!)?.type).toBe("node_flow_node"); }); + it.each([ + "Credential is not active.", + "Credential is outside the project scope.", + "Credential kind is not approved for this consumer.", + "Credential does not approve every required capability.", + "Credential encrypted state or key custody is unavailable.", + ])("fails a changed credential policy before invoking the provider executor: %s", async (denial) => { + const executeProvider = vi.fn(); + const resolveCredentialId = vi.fn().mockRejectedValue(new Error(denial)); + const { dir, projectRepository, nodeFlowRepository, runtime } = await createRuntime( + { executeProvider } as Partial, + { resolveCredentialId }, + ); + const project = projectRepository.createProject({ name: "Credential Denial Project", sourceType: "local", sourceRef: dir }); + const flow = nodeFlowRepository.createFlow(project.id, { title: "Credential denial", graph: { + nodes: [{ + id: "prompt", + type: "provider_prompt", + title: "Prompt", + data: { provider: "mockup-cli", prompt: "Answer" }, + credentialBindings: [{ slot: "provider", credentialId: "credential-1" }], + }], + edges: [], + } }); + + const result = await runtime.runFlow(project.id, flow.id, {}); + + expect(result.run.status).toBe("failed"); + expect(result.run.errorMessage).toContain(denial); + expect(resolveCredentialId).toHaveBeenCalledTimes(1); + expect(executeProvider).not.toHaveBeenCalled(); + }); + + it("re-evaluates definition capabilities after publication and before the secret read", async () => { + const executeProvider = vi.fn(); + const resolveCredentialId = vi.fn().mockRejectedValue(new Error("Credential capability changed after review.")); + const { dir, projectRepository, nodeFlowRepository, runtime } = await createRuntime( + { executeProvider } as Partial, + { resolveCredentialId }, + ); + const project = projectRepository.createProject({ name: "Policy Change Project", sourceType: "local", sourceRef: dir }); + const flow = nodeFlowRepository.createFlow(project.id, { title: "Policy change", graph: { + nodes: [{ + id: "prompt", + type: "provider_prompt", + title: "Prompt", + data: { provider: "mockup-cli", prompt: "Answer" }, + credentialBindings: [{ slot: "provider", credentialId: "credential-1" }], + }], + edges: [], + } }); + const requirement = resolveNodeDefinition("provider_prompt", 1)!.credentials[0]!; + const originalCapabilities = requirement.requiredCapabilities; + requirement.requiredCapabilities = ["provider.execute"]; + try { + const result = await runtime.runFlow(project.id, flow.id, {}); + expect(result.run.status).toBe("failed"); + expect(resolveCredentialId).toHaveBeenCalledWith(expect.objectContaining({ + allowedKinds: ["provider"], + requiredCapabilities: ["provider.execute"], + })); + expect(executeProvider).not.toHaveBeenCalled(); + } finally { + requirement.requiredCapabilities = originalCapabilities; + } + }); + + it("fails a newly required slot before resolving or invoking the provider", async () => { + const executeProvider = vi.fn(); + const resolveCredentialId = vi.fn(); + const { dir, projectRepository, nodeFlowRepository, runtime } = await createRuntime( + { executeProvider } as Partial, + { resolveCredentialId }, + ); + const project = projectRepository.createProject({ name: "Required Runtime Slot Project", sourceType: "local", sourceRef: dir }); + const flow = nodeFlowRepository.createFlow(project.id, { title: "Required at runtime", graph: { + nodes: [{ id: "prompt", type: "provider_prompt", title: "Prompt", data: { provider: "mockup-cli", prompt: "Answer" } }], + edges: [], + } }); + const requirement = resolveNodeDefinition("provider_prompt", 1)!.credentials[0]!; + const originalRequired = requirement.required; + requirement.required = true; + try { + const result = await runtime.runFlow(project.id, flow.id, {}); + expect(result.run.status).toBe("failed"); + expect(result.run.errorMessage).toMatch(/requires credential slot provider/i); + expect(resolveCredentialId).not.toHaveBeenCalled(); + expect(executeProvider).not.toHaveBeenCalled(); + } finally { + requirement.required = originalRequired; + } + }); + it("redacts a resolved credential echoed by provider output and retry errors from every runtime record", async () => { const credentialCanary = "CODEUX_PROVIDER_CANARY_X9Q7"; const persistedBoundaryValues: unknown[] = []; diff --git a/tests/backend/services/node-flow-service.test.ts b/tests/backend/services/node-flow-service.test.ts index 951e451b92..d60ad1f43f 100644 --- a/tests/backend/services/node-flow-service.test.ts +++ b/tests/backend/services/node-flow-service.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; @@ -7,10 +7,12 @@ import { ProjectManagementRepository } from "../../../src/repositories/project-m import { NodeFlowRepository } from "../../../src/repositories/node-flow-repository.js"; import { NodeFlowService } from "../../../src/services/node-flow-service.js"; import type { NodeFlowGraph } from "../../../src/contracts/node-flow-types.js"; +import type { CredentialBroker } from "../../../src/services/credentials/credential-broker.js"; +import { registerCustomNodeDefinition, resolveNodeDefinition } from "../../../src/domain/node-flows/node-definition-registry.js"; const tempDirs: string[] = []; -async function createService(): Promise<{ +async function createService(credentialBroker?: Partial): Promise<{ dir: string; projectRepository: ProjectManagementRepository; service: NodeFlowService; @@ -21,7 +23,7 @@ async function createService(): Promise<{ return { dir, projectRepository: new ProjectManagementRepository(storage), - service: new NodeFlowService(new NodeFlowRepository(storage)), + service: new NodeFlowService(new NodeFlowRepository(storage), undefined, credentialBroker as CredentialBroker | undefined), }; } @@ -33,6 +35,50 @@ const validGraph = (): NodeFlowGraph => ({ edges: [{ fromNodeId: "input", toNodeId: "agent" }], }); +const REQUIRED_NODE_TYPE = "custom.required-credential-policy-fixture"; + +function registerRequiredCredentialDefinition(): void { + if (resolveNodeDefinition(REQUIRED_NODE_TYPE, 1)) return; + registerCustomNodeDefinition({ + type: REQUIRED_NODE_TYPE, + version: 1, + executable: true, + executionKind: "custom", + configurationSchema: { type: "object" }, + ui: { label: "Required credential", description: "", category: "custom", widgetSchema: { fields: [] } }, + ports: [], + credentials: [{ + slot: "jobs", + label: "Jobs API", + required: true, + allowedKinds: ["http.token"], + requiredCapabilities: ["jobs.list"], + }], + capabilities: ["credentials.read"], + sideEffect: "read", + defaultPolicy: {}, + documentation: "docs/architecture/custom-nodes.md", + deprecation: { deprecated: false }, + }); +} + +function compatibleAssessment(credentialId: string) { + return { + credentialId, + projectId: "project", + compatible: true, + backendReady: true, + configured: true, + active: true, + projectAccess: true, + kindAllowed: true, + capabilitiesAllowed: true, + missingCapabilities: [], + issues: [], + metadata: null, + }; +} + afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -68,12 +114,12 @@ describe("NodeFlowService", () => { sourceRef: dir, }); - const created = service.create(project.id, { + const created = await service.create(project.id, { title: " Intake flow ", description: " Collects details ", graph: validGraph(), }); - const updated = service.update(created.id, { + const updated = await service.update(created.id, { title: " Intake flow updated ", description: " Updated description ", }); @@ -93,10 +139,10 @@ describe("NodeFlowService", () => { const graph = validGraph(); graph.edges.push({ fromNodeId: "agent", toNodeId: "input" }); - expect(() => service.create(project.id, { + await expect(service.create(project.id, { title: "Bad flow", graph, - })).toThrow(/acyclic|validation failed/i); + })).rejects.toThrow(/acyclic|validation failed/i); expect(service.list(project.id).flows).toEqual([]); }); @@ -116,14 +162,14 @@ describe("NodeFlowService", () => { it("applies optimistic draft patches without overwriting conflicting edits", async () => { const { dir, projectRepository, service } = await createService(); const project = projectRepository.createProject({ name: "Draft Project", sourceType: "local", sourceRef: dir }); - const draft = service.createDraft(project.id, { title: "Draft", graph: validGraph() }); + const draft = await service.createDraft(project.id, { title: "Draft", graph: validGraph() }); - const updated = service.patchDraft(draft.flowId, { + const updated = await service.patchDraft(draft.flowId, { projectId: project.id, draftRevision: draft.draftRevision, operations: [{ op: "set_metadata", metadata: { purpose: "review" } }], }); - const conflict = service.patchDraft(draft.flowId, { + const conflict = await service.patchDraft(draft.flowId, { projectId: project.id, draftRevision: draft.draftRevision, operations: [{ op: "set_metadata", metadata: { purpose: "overwrite" } }], @@ -137,14 +183,136 @@ describe("NodeFlowService", () => { it("publishes reviewed drafts and creates rollback drafts without replacing history", async () => { const { dir, projectRepository, service } = await createService(); const project = projectRepository.createProject({ name: "Publish Project", sourceType: "local", sourceRef: dir }); - const draft = service.createDraft(project.id, { title: "Draft", graph: validGraph() }); + const draft = await service.createDraft(project.id, { title: "Draft", graph: validGraph() }); expect(draft.publishedVersion).toBeNull(); - const published = service.publishDraft(project.id, draft.flowId, 1, "reviewer"); + const published = await service.publishDraft(project.id, draft.flowId, 1, "reviewer"); expect(published.publishedVersion).toBe(1); - const second = service.patchDraft(draft.flowId, { projectId: project.id, draftRevision: 1, title: "Second" }); - const rollback = service.rollback(project.id, draft.flowId, 1, second.draft!.draftRevision); + const second = await service.patchDraft(draft.flowId, { projectId: project.id, draftRevision: 1, title: "Second" }); + const rollback = await service.rollback(project.id, draft.flowId, 1, second.draft!.draftRevision); expect(rollback.draftRevision).toBe(3); expect(rollback.name).toBe("Draft"); expect(service.compareVersions(project.id, draft.flowId, 1, 3)).toMatchObject({ fromVersion: 1, toVersion: 3 }); }); + + it("keeps unbound optional slots reviewable and publishable", async () => { + const { dir, projectRepository, service } = await createService(); + const project = projectRepository.createProject({ name: "Optional Slot Project", sourceType: "local", sourceRef: dir }); + const draft = await service.createDraft(project.id, { title: "Optional slot", graph: { + nodes: [{ id: "prompt", type: "provider_prompt", title: "Prompt", data: { prompt: "Hello" } }], + edges: [], + } }); + + expect(draft.requiredCredentials).toEqual([expect.objectContaining({ + nodeId: "prompt", + slot: "provider", + required: false, + status: "missing", + requiredCapabilities: ["read"], + })]); + expect(draft.valid).toBe(true); + await expect(service.publishDraft(project.id, draft.flowId, draft.draftRevision, "reviewer")) + .resolves.toMatchObject({ publishedVersion: 1 }); + }); + + it("keeps required bindings canonical in the graph and marks the request endpoint non-persistent", async () => { + registerRequiredCredentialDefinition(); + const assessCompatibility = vi.fn(async (credentialId: string) => credentialId === "credential-good" + ? compatibleAssessment(credentialId) + : { ...compatibleAssessment(credentialId), compatible: false, active: false, issues: ["not_active" as const] }); + const { dir, projectRepository, service } = await createService({ assessCompatibility }); + const project = projectRepository.createProject({ name: "Required Slot Project", sourceType: "local", sourceRef: dir }); + const draft = await service.createDraft(project.id, { title: "Required slot", graph: { + nodes: [{ id: "custom", type: REQUIRED_NODE_TYPE, title: "Custom" }], + edges: [], + } }); + + expect(draft.valid).toBe(false); + expect(draft.policyFindings).toContainEqual(expect.objectContaining({ code: "missing_credential_binding" })); + await expect(service.publishDraft(project.id, draft.flowId, draft.draftRevision, "reviewer")).rejects.toThrow(/valid draft/i); + await expect(service.requestCredential(project.id, draft.flowId, "custom", "jobs")).resolves.toMatchObject({ + requestStatus: "requested", + persistence: "none", + bindingChanged: false, + credentialId: null, + }); + expect(service.get(draft.flowId)?.graph.nodes[0]?.credentialBindings).toEqual([]); + + const denied = await service.patchDraft(draft.flowId, { + projectId: project.id, + draftRevision: draft.draftRevision, + operations: [{ op: "upsert_node", node: { + id: "custom", type: REQUIRED_NODE_TYPE, title: "Custom", + credentialBindings: [{ slot: "jobs", credentialId: "credential-revoked" }], + } }], + }); + expect(denied.draft?.requiredCredentials[0]).toMatchObject({ status: "denied", active: false, compatibilityIssues: ["not_active"] }); + + const replacement = await service.patchDraft(draft.flowId, { + projectId: project.id, + draftRevision: denied.draft!.draftRevision, + operations: [{ op: "upsert_node", node: { + id: "custom", type: REQUIRED_NODE_TYPE, title: "Custom", + credentialBindings: [{ slot: "jobs", credentialId: "credential-good" }], + } }], + }); + expect(replacement.draft?.requiredCredentials[0]).toMatchObject({ credentialId: "credential-good", status: "bound" }); + expect(service.get(draft.flowId)?.graph.nodes[0]?.credentialBindings).toEqual([{ slot: "jobs", credentialId: "credential-good" }]); + await expect(service.publishDraft(project.id, draft.flowId, replacement.draft!.draftRevision, "reviewer")).resolves.toBeDefined(); + expect(assessCompatibility).toHaveBeenLastCalledWith("credential-good", { + projectId: project.id, + allowedKinds: ["http.token"], + requiredCapabilities: ["jobs.list"], + }); + }); + + it.each([ + ["backend_unavailable", "credential_backend_unavailable", { backendReady: false }], + ["not_configured", "credential_not_configured", { configured: false }], + ["not_active", "credential_not_active", { active: false }], + ["project_access_denied", "credential_project_access_denied", { projectAccess: false }], + ["kind_not_allowed", "credential_kind_not_allowed", { kindAllowed: false }], + ["capability_missing", "credential_capability_missing", { capabilitiesAllowed: false, missingCapabilities: ["jobs.list"] }], + ] as const)("blocks publication with a stable %s compatibility finding", async (issue, findingCode, state) => { + registerRequiredCredentialDefinition(); + const assessCompatibility = vi.fn(async (credentialId: string) => ({ + ...compatibleAssessment(credentialId), + ...state, + compatible: false, + issues: [issue], + })); + const { dir, projectRepository, service } = await createService({ assessCompatibility }); + const project = projectRepository.createProject({ name: "Denied Slot Project", sourceType: "local", sourceRef: dir }); + const draft = await service.createDraft(project.id, { title: "Denied slot", graph: { + nodes: [{ + id: "custom", type: REQUIRED_NODE_TYPE, title: "Custom", + credentialBindings: [{ slot: "jobs", credentialId: "credential-denied" }], + }], + edges: [], + } }); + + expect(draft.valid).toBe(false); + expect(draft.policyFindings).toContainEqual(expect.objectContaining({ code: findingCode, nodeId: "custom" })); + await expect(service.publishDraft(project.id, draft.flowId, draft.draftRevision, "reviewer")).rejects.toThrow(/valid draft/i); + }); + + it("applies the same compatibility gate to the legacy direct publication path", async () => { + registerRequiredCredentialDefinition(); + const assessCompatibility = vi.fn(async (credentialId: string) => ({ + ...compatibleAssessment(credentialId), + compatible: false, + active: false, + issues: ["not_active" as const], + })); + const { dir, projectRepository, service } = await createService({ assessCompatibility }); + const project = projectRepository.createProject({ name: "Legacy Publication Project", sourceType: "local", sourceRef: dir }); + + await expect(service.create(project.id, { title: "Legacy publication", graph: { + nodes: [{ + id: "custom", type: REQUIRED_NODE_TYPE, title: "Custom", + credentialBindings: [{ slot: "jobs", credentialId: "credential-revoked" }], + }], + edges: [], + } })).rejects.toThrow(/credential policy must pass review/i); + expect(service.list(project.id).flows).toEqual([]); + }); }); From cb37472b0090f0e450af0ff52743fd9bc68612d8 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 22:14:36 +0000 Subject: [PATCH 07/22] fix(task T03): address qa review via codex --- docs-web/architecture/node-flow-foundation.md | 2 +- .../architecture-node-flow-foundation.mdx | 2 +- docs/architecture/node-flow-foundation.md | 2 +- src/services/node-flow-agent-skill-service.ts | 4 ++- .../node-flow-agent-skill-service.test.ts | 25 ++++++++++++++++++- 5 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs-web/architecture/node-flow-foundation.md b/docs-web/architecture/node-flow-foundation.md index 5adbbe2e5d..8c811d3b10 100644 --- a/docs-web/architecture/node-flow-foundation.md +++ b/docs-web/architecture/node-flow-foundation.md @@ -20,7 +20,7 @@ A validated custom definition becomes executable only after its immutable artifa Validation resolves each definition and checks configuration, port handles and schemas, policies, graph bounds, and cycles. Migrated Graph v1 and canonical Graph v2 inputs fail closed: malformed nodes, edges, ports, credential bindings, definition references, capabilities, policies, schemas, and metadata produce deterministic field-level issues at their original paths without discarding safe siblings. Repeated validation preserves issue ordering. It rejects plaintext secret-shaped fields and generated or custom source in graph JSON. The dashboard receives credential binding ids and metadata-only states; resolved values remain behind the credential broker. Credential values and secret-shaped payloads are redacted before invocation messages, attempts, diagnostics, route responses, and debugger output are persisted or rendered. -Draft review reports requested capabilities, side effects, credential status, policy findings, and a non-executing dry run. For each bound slot it uses the credential broker's metadata-only compatibility contract and reports backend readiness, configured/active state, project access, kind and capability compatibility, and missing capabilities. Stable findings for missing required bindings and every denial block draft and legacy create/update publication; an unbound optional slot remains valid. +Draft review reports requested capabilities, side effects, credential status, policy findings, and a non-executing dry run. For each bound slot it uses the credential broker's metadata-only compatibility contract and reports backend readiness, configured/active state, project access, kind and capability compatibility, and missing capabilities. Stable findings for missing required bindings and every denial block draft publication, legacy create/update publication, and attached-flow execution; an unbound optional slot remains valid in all three paths. At runtime the current versioned definition is checked again before an executor runs. Undeclared, duplicate, or newly required-but-missing slots fail closed, and the broker receives the same allowed kinds and required capabilities before one authorized secret read. Revocation, restriction, replacement, access changes, and encrypted-backend failure therefore stop execution before the node executor. Graph `credentialBindings` remain canonical; the legacy credential-request endpoint explicitly reports that it is non-persistent and never changes them. Runs select an immutable pinned or latest-published snapshot. diff --git a/docs-web/content/docs/architecture-node-flow-foundation.mdx b/docs-web/content/docs/architecture-node-flow-foundation.mdx index 5adbbe2e5d..8c811d3b10 100644 --- a/docs-web/content/docs/architecture-node-flow-foundation.mdx +++ b/docs-web/content/docs/architecture-node-flow-foundation.mdx @@ -20,7 +20,7 @@ A validated custom definition becomes executable only after its immutable artifa Validation resolves each definition and checks configuration, port handles and schemas, policies, graph bounds, and cycles. Migrated Graph v1 and canonical Graph v2 inputs fail closed: malformed nodes, edges, ports, credential bindings, definition references, capabilities, policies, schemas, and metadata produce deterministic field-level issues at their original paths without discarding safe siblings. Repeated validation preserves issue ordering. It rejects plaintext secret-shaped fields and generated or custom source in graph JSON. The dashboard receives credential binding ids and metadata-only states; resolved values remain behind the credential broker. Credential values and secret-shaped payloads are redacted before invocation messages, attempts, diagnostics, route responses, and debugger output are persisted or rendered. -Draft review reports requested capabilities, side effects, credential status, policy findings, and a non-executing dry run. For each bound slot it uses the credential broker's metadata-only compatibility contract and reports backend readiness, configured/active state, project access, kind and capability compatibility, and missing capabilities. Stable findings for missing required bindings and every denial block draft and legacy create/update publication; an unbound optional slot remains valid. +Draft review reports requested capabilities, side effects, credential status, policy findings, and a non-executing dry run. For each bound slot it uses the credential broker's metadata-only compatibility contract and reports backend readiness, configured/active state, project access, kind and capability compatibility, and missing capabilities. Stable findings for missing required bindings and every denial block draft publication, legacy create/update publication, and attached-flow execution; an unbound optional slot remains valid in all three paths. At runtime the current versioned definition is checked again before an executor runs. Undeclared, duplicate, or newly required-but-missing slots fail closed, and the broker receives the same allowed kinds and required capabilities before one authorized secret read. Revocation, restriction, replacement, access changes, and encrypted-backend failure therefore stop execution before the node executor. Graph `credentialBindings` remain canonical; the legacy credential-request endpoint explicitly reports that it is non-persistent and never changes them. Runs select an immutable pinned or latest-published snapshot. diff --git a/docs/architecture/node-flow-foundation.md b/docs/architecture/node-flow-foundation.md index 77ffbf50c9..501b3cdabe 100644 --- a/docs/architecture/node-flow-foundation.md +++ b/docs/architecture/node-flow-foundation.md @@ -131,7 +131,7 @@ Validation resolves definitions and checks configuration, handles, policies, gra Draft review combines structural validation, capability and side-effect policy findings, credential-slot status, and a non-executing dry run. The dashboard receives credential ids and status metadata only, never resolved secret values. Publication requires the current revision, a valid policy review, and all credential requirements bound. Runs resolve immutable pinned or latest-published snapshots. -Credential review uses the credential broker's metadata-only compatibility contract for every bound slot. Results expose backend readiness, configured and active state, project access, kind compatibility, capability compatibility, and missing capabilities. Missing required bindings and every compatibility denial have stable policy-finding codes and block both draft publication and the legacy create/update publication path; an unbound optional slot does not. `POST /api/node-flow-drafts/:flowId/credential-requests` remains a compatibility-only, non-persistent request and explicitly reports that it did not change `NodeFlowNode.credentialBindings`, which is the sole binding authority. +Credential review uses the credential broker's metadata-only compatibility contract for every bound slot. Results expose backend readiness, configured and active state, project access, kind compatibility, capability compatibility, and missing capabilities. Missing required bindings and every compatibility denial have stable policy-finding codes and block draft publication, the legacy create/update publication path, and attached-flow execution; an unbound optional slot blocks none of them. `POST /api/node-flow-drafts/:flowId/credential-requests` remains a compatibility-only, non-persistent request and explicitly reports that it did not change `NodeFlowNode.credentialBindings`, which is the sole binding authority. The run debugger reads persisted runs, node runs, numbered attempts, approvals, retry decisions, invocation links, timing, and cancellation state. Responses and persisted payloads are redacted before display. Scheduling delegates to the scheduler and retains pinned-versus-latest publication semantics. diff --git a/src/services/node-flow-agent-skill-service.ts b/src/services/node-flow-agent-skill-service.ts index a4d8bc5b81..85862e8a36 100644 --- a/src/services/node-flow-agent-skill-service.ts +++ b/src/services/node-flow-agent-skill-service.ts @@ -41,7 +41,9 @@ export class NodeFlowAgentSkillService { if (!capability) throw new EntityNotFoundError("Node flow is not attached to the initiating agent."); const review = await this.nodeFlowService.validateDraft(input.projectId, input.flowId); if (review.publishedVersion === null) throw new ValidationError("Attached node flow has not been published."); - if (review.requiredCredentials.some((credential) => credential.status !== "bound")) { + if (review.requiredCredentials.some((credential) => ( + credential.status === "denied" || (credential.required && credential.status === "missing") + ))) { throw new ValidationError("Attached node flow credential policy is not satisfied."); } return await this.nodeFlowService.runFlow(input.projectId, input.flowId, input.parameters ?? {}, { diff --git a/tests/backend/services/node-flow-agent-skill-service.test.ts b/tests/backend/services/node-flow-agent-skill-service.test.ts index 4ae189f4a3..d6d57c7a2d 100644 --- a/tests/backend/services/node-flow-agent-skill-service.test.ts +++ b/tests/backend/services/node-flow-agent-skill-service.test.ts @@ -28,6 +28,29 @@ describe("NodeFlowAgentSkillService", () => { const base = { get: vi.fn(() => ({ id: "flow-1", projectId: "project-1", graph: { nodes: [], edges: [] } })), runFlow: vi.fn() }; await expect(new NodeFlowAgentSkillService({ ...base, listAgentSkillsForAgent: () => [] } as never).runAttachedFlow({ projectId: "project-1", flowId: "flow-1", agentPresetId: "agent-1" })).rejects.toThrow(/not attached/i); await expect(new NodeFlowAgentSkillService({ ...base, listAgentSkillsForAgent: () => [{ flowId: "flow-1", skillName: "x", description: "" }], validateDraft: () => ({ publishedVersion: null, requiredCredentials: [] }) } as never).runAttachedFlow({ projectId: "project-1", flowId: "flow-1", agentPresetId: "agent-1" })).rejects.toThrow(/not been published/i); - await expect(new NodeFlowAgentSkillService({ ...base, listAgentSkillsForAgent: () => [{ flowId: "flow-1", skillName: "x", description: "" }], validateDraft: () => ({ publishedVersion: 1, requiredCredentials: [{ status: "missing" }] }) } as never).runAttachedFlow({ projectId: "project-1", flowId: "flow-1", agentPresetId: "agent-1" })).rejects.toThrow(/credential policy/i); + await expect(new NodeFlowAgentSkillService({ ...base, listAgentSkillsForAgent: () => [{ flowId: "flow-1", skillName: "x", description: "" }], validateDraft: () => ({ publishedVersion: 1, requiredCredentials: [{ required: true, status: "missing" }] }) } as never).runAttachedFlow({ projectId: "project-1", flowId: "flow-1", agentPresetId: "agent-1" })).rejects.toThrow(/credential policy/i); + await expect(new NodeFlowAgentSkillService({ ...base, listAgentSkillsForAgent: () => [{ flowId: "flow-1", skillName: "x", description: "" }], validateDraft: () => ({ publishedVersion: 1, requiredCredentials: [{ required: false, status: "denied" }] }) } as never).runAttachedFlow({ projectId: "project-1", flowId: "flow-1", agentPresetId: "agent-1" })).rejects.toThrow(/credential policy/i); + expect(base.runFlow).not.toHaveBeenCalled(); + }); + + it("executes a published attached flow when an optional credential slot is unbound", async () => { + const runFlow = vi.fn(async () => ({ run: { id: "run-optional" }, nodeRuns: [], output: { ok: true } })); + const service = new NodeFlowAgentSkillService({ + listAgentSkillsForAgent: () => [{ flowId: "flow-1", skillName: "x", description: "" }], + get: () => ({ id: "flow-1", projectId: "project-1", graph: { nodes: [], edges: [] } }), + validateDraft: () => ({ + publishedVersion: 1, + requiredCredentials: [{ required: false, status: "missing" }], + }), + runFlow, + } as never); + + await expect(service.runAttachedFlow({ + projectId: "project-1", + flowId: "flow-1", + agentPresetId: "agent-1", + parameters: { prompt: "safe" }, + })).resolves.toMatchObject({ run: { id: "run-optional" } }); + expect(runFlow).toHaveBeenCalledOnce(); }); }); From 1073eb8c7516a2a797012fe0182eab9fe57f290c Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 22:18:08 +0000 Subject: [PATCH 08/22] feat(task T05): implement via codex --- .../__tests__/CustomDashboardViewer.test.tsx | 28 + .../src/v2/lib/custom-dashboard-runtime.ts | 29 +- .../custom-dashboard-foundation.md | 28 +- ...chitecture-custom-dashboard-foundation.mdx | 28 +- docs-web/content/docs/developer-http-api.mdx | 5 +- .../docs/developer-management-actions.mdx | 5 +- docs-web/content/docs/developer-mcp-tools.mdx | 6 +- .../docs/operations-credential-security.mdx | 2 + .../docs/user-dashboard-custom-dashboards.mdx | 22 +- docs-web/developer/http-api.md | 5 +- docs-web/developer/management-actions.md | 5 +- docs-web/developer/mcp-tools.md | 6 +- docs-web/operations/credential-security.md | 2 + docs-web/user/dashboard/custom-dashboards.md | 22 +- .../custom-dashboard-foundation.md | 28 +- docs/dashboard/custom-dashboards.md | 33 +- docs/mcp/tools-and-contracts.md | 11 +- docs/operations/credential-security.md | 2 + src/app/dependency-factory/core-factory.ts | 10 + .../dependency-factory/dashboard-factory.ts | 1 + src/app/dependency-factory/mcp-factory.ts | 1 + .../lifecycle/dashboard-lifecycle-service.ts | 3 + src/contracts/internal-management-types.ts | 8 +- src/contracts/mcp-tool-definitions.ts | 12 +- src/mcp/management-tool-handler.ts | 5 + .../management/custom-dashboard-actions.ts | 125 ++++- src/server/code-ux-server.ts | 3 + src/server/custom-dashboard-routes.ts | 76 ++- src/server/dashboard-server.ts | 2 + src/server/http-errors.ts | 5 + ...om-dashboard-credential-binding-service.ts | 496 ++++++++++++++++++ .../custom-dashboard-validation-service.ts | 15 + .../custom-dashboard-validation-utils.ts | 48 +- src/services/headless-auth-service.ts | 3 +- ...anagement-custom-dashboard-actions.test.ts | 151 +++++- .../server/custom-dashboard-routes.test.ts | 147 +++++- ...ustom-dashboard-validation-service.test.ts | 249 +++++++++ .../custom-dashboard-validation-utils.test.ts | 67 +++ 38 files changed, 1588 insertions(+), 106 deletions(-) create mode 100644 src/services/custom-dashboard-credential-binding-service.ts diff --git a/dashboard/src/v2/components/custom-dashboards/__tests__/CustomDashboardViewer.test.tsx b/dashboard/src/v2/components/custom-dashboards/__tests__/CustomDashboardViewer.test.tsx index 38f0387f87..a70a3812e8 100644 --- a/dashboard/src/v2/components/custom-dashboards/__tests__/CustomDashboardViewer.test.tsx +++ b/dashboard/src/v2/components/custom-dashboards/__tests__/CustomDashboardViewer.test.tsx @@ -56,11 +56,15 @@ const dashboard: CustomDashboardRecord = { }, styleguide: { tone: "operational" }, runtimeMetadata: {}, + credentialBindings: [{ slotId: "metrics_api", credentialId: "viewer-binding-id-canary" }], + credentialBindingRevision: 2, publishedRevisionId: "revision-1", createdAt: "2026-07-07T00:00:00.000Z", updatedAt: "2026-07-07T00:00:00.000Z", }; +const STORED_CREDENTIAL_PLAINTEXT_CANARY = "CUSTOM_DASHBOARD_REAL_SECRET_CANARY_7f8d9a"; + const revision: CustomDashboardRevisionRecord = { id: "revision-1", dashboardId: "dashboard-1", @@ -73,6 +77,7 @@ const revision: CustomDashboardRevisionRecord = { validationStatus: "passed", validationReport: { valid: true, summary: "Passed", issues: [] }, runtimeMetadata: {}, + credentialBindings: [{ slotId: "metrics_api", credentialId: "viewer-binding-id-canary" }], validatedAt: "2026-07-07T00:00:00.000Z", createdAt: "2026-07-07T00:00:00.000Z", updatedAt: "2026-07-07T00:00:00.000Z", @@ -109,6 +114,8 @@ describe("CustomDashboardViewer", () => { const iframe = screen.getByTitle("Published custom dashboard: Delivery Pulse"); expect(iframe).toHaveAttribute("sandbox", "allow-forms allow-popups allow-scripts"); expect(iframe).toHaveAttribute("srcdoc", expect.stringContaining("Published dashboard")); + expect(iframe).not.toHaveAttribute("srcdoc", expect.stringContaining("viewer-binding-id-canary")); + expect(iframe).not.toHaveAttribute("srcdoc", expect.stringContaining(STORED_CREDENTIAL_PLAINTEXT_CANARY)); fireEvent.click(screen.getByRole("button", { name: "Refresh published dashboard" })); expect(onRefresh).toHaveBeenCalledTimes(1); @@ -219,6 +226,25 @@ describe("CustomDashboardViewer", () => { expect(await screen.findByRole("alert", { name: "Custom dashboard runtime failure" })).toHaveTextContent("Frame exploded"); }); + it("ignores runtime messages from any window other than the isolated dashboard frame", async () => { + render( + , + ); + + window.dispatchEvent(new MessageEvent("message", { + data: { type: "codeux-custom-dashboard:runtime-error", message: "Hostile message" }, + source: window, + })); + + await Promise.resolve(); + expect(screen.queryByRole("alert", { name: "Custom dashboard runtime failure" })).not.toBeInTheDocument(); + }); + it("returns source errors to the isolated frame without throwing in the app shell", async () => { render( { "*", ); }); + expect(JSON.stringify(postMessage.mock.calls)).not.toContain("viewer-binding-id-canary"); + expect(JSON.stringify(postMessage.mock.calls)).not.toContain(STORED_CREDENTIAL_PLAINTEXT_CANARY); }); }); diff --git a/dashboard/src/v2/lib/custom-dashboard-runtime.ts b/dashboard/src/v2/lib/custom-dashboard-runtime.ts index a287bad268..1e77ef2373 100644 --- a/dashboard/src/v2/lib/custom-dashboard-runtime.ts +++ b/dashboard/src/v2/lib/custom-dashboard-runtime.ts @@ -78,17 +78,18 @@ export function resolvePublishedCustomDashboardRuntime( const publishedRevision = dashboard.publishedRevisionId ? revisions.find((revision) => revision.id === dashboard.publishedRevisionId) ?? null : null; + const safePublishedRevision = publishedRevision ? omitRevisionCredentialBindings(publishedRevision) : null; const validationReport = getLastValidationReport(revisions); if (dashboard.status === "archived") { - return { status: "blocked", reason: "Archived custom dashboards cannot be opened.", validationReport, publishedRevision }; + return { status: "blocked", reason: "Archived custom dashboards cannot be opened.", validationReport, publishedRevision: safePublishedRevision }; } if (dashboard.status !== "published") { return { status: "blocked", reason: "Only published custom dashboards can be opened. Validate and publish a revision first.", validationReport, - publishedRevision, + publishedRevision: safePublishedRevision, }; } if (!dashboard.publishedRevisionId || !publishedRevision) { @@ -96,7 +97,7 @@ export function resolvePublishedCustomDashboardRuntime( status: "blocked", reason: "This custom dashboard has no published revision.", validationReport, - publishedRevision, + publishedRevision: safePublishedRevision, }; } if (publishedRevision.validationStatus !== "passed" || publishedRevision.validationReport?.valid !== true) { @@ -104,16 +105,18 @@ export function resolvePublishedCustomDashboardRuntime( status: "blocked", reason: "The published revision no longer has a passed validation report.", validationReport: publishedRevision.validationReport ?? validationReport, - publishedRevision, + publishedRevision: safePublishedRevision, }; } + const readyRevision = omitRevisionCredentialBindings(publishedRevision); + return { status: "ready", runtime: { - dashboard, - revision: publishedRevision, - document: buildCustomDashboardFrameDocument(dashboard, publishedRevision), + dashboard: omitDashboardCredentialBindings(dashboard), + revision: readyRevision, + document: buildCustomDashboardFrameDocument(dashboard, readyRevision), }, }; } @@ -351,7 +354,7 @@ function buildBridgeBootstrapScript(config: Record): string { " window.parent.postMessage({ type: 'codeux-custom-dashboard:source-request', requestId, sourceId }, '*');", " });", " window.addEventListener('message', (event) => {", - ` if (!event.data || event.data.type !== '${CUSTOM_DASHBOARD_SOURCE_RESPONSE_TYPE}') return;`, + ` if (event.source !== window.parent || !event.data || event.data.type !== '${CUSTOM_DASHBOARD_SOURCE_RESPONSE_TYPE}') return;`, " const entry = pending.get(event.data.requestId);", " if (!entry) return;", " pending.delete(event.data.requestId);", @@ -372,6 +375,16 @@ function buildBridgeBootstrapScript(config: Record): string { ].join("\n"); } +function omitDashboardCredentialBindings(dashboard: CustomDashboardRecord): CustomDashboardRecord { + const { credentialBindings: _credentialBindings, ...safe } = dashboard; + return safe; +} + +function omitRevisionCredentialBindings(revision: CustomDashboardRevisionRecord): CustomDashboardRevisionRecord { + const { credentialBindings: _credentialBindings, ...safe } = revision; + return safe; +} + function injectBootstrapIntoHtml(html: string, bootstrap: string, title: string): string { const script = ``; if (/<\/head>/i.test(html)) { diff --git a/docs-web/architecture/custom-dashboard-foundation.md b/docs-web/architecture/custom-dashboard-foundation.md index f19f493a6c..69b20c78f8 100644 --- a/docs-web/architecture/custom-dashboard-foundation.md +++ b/docs-web/architecture/custom-dashboard-foundation.md @@ -8,10 +8,10 @@ The shared contracts live in `src/contracts/custom-dashboard-types.ts`. Primary records: -- `CustomDashboardRecord` stores the mutable project-scoped draft state, status, manifest, generated file bundle, source node graph, styleguide JSON, runtime metadata JSON, and active published revision id. -- `CustomDashboardRevisionRecord` stores immutable dashboard bundle snapshots. Manifest, files, source node graph, and styleguide data are copied into each revision so future draft edits do not mutate validation or publication history. +- `CustomDashboardRecord` stores the mutable project-scoped draft state, status, manifest, generated file bundle, source node graph, styleguide JSON, runtime metadata JSON, credential-ID bindings, an optimistic binding revision, and the active published revision id. +- `CustomDashboardRevisionRecord` stores immutable dashboard bundle snapshots. Manifest, files, source node graph, styleguide data, and credential-ID bindings are copied into each revision so future draft edits or rebindings do not mutate validation or publication history. - `CustomDashboardValidationSessionRecord` stores validation attempts for a revision, including queued/building/running/passed/failed/cancelled status, validation report JSON, runtime metadata, and timestamps. -- `CustomDashboardManifest` describes the generated dashboard bundle with schema version, title, entry file, file paths, optional data-source graph, and metadata. +- `CustomDashboardManifest` describes the generated dashboard bundle with schema version, title, entry file, file paths, optional data-source graph, bounded credential-slot declarations, and metadata. A slot declares its build/runtime phase, required state, allowed credential kinds, and required capabilities; it never contains secret material. Dashboard status values are `draft`, `validating`, `validated`, `published`, `rejected`, and `archived`. Validation status values are `queued`, `building`, `running`, `passed`, `failed`, and `cancelled`. @@ -21,8 +21,8 @@ SQLite tables are created in both the initial schema and startup migrations: | Table | Purpose | | --- | --- | -| `custom_dashboards` | Current mutable project-scoped draft state, including manifest JSON, file bundle JSON, source node graph JSON, styleguide JSON, runtime metadata JSON, status, and timestamps. | -| `custom_dashboard_revisions` | Immutable revision snapshots with copied manifest, files, source graph, styleguide, runtime metadata, validation status/report, validated timestamp, and revision number. | +| `custom_dashboards` | Current mutable project-scoped draft state, including manifest JSON, file bundle JSON, source node graph JSON, styleguide JSON, runtime metadata JSON, dedicated credential binding JSON, optimistic binding revision, status, and timestamps. | +| `custom_dashboard_revisions` | Immutable revision snapshots with copied manifest, files, source graph, styleguide, runtime metadata, dedicated credential binding JSON, validation status/report, validated timestamp, and revision number. | | `custom_dashboard_validation_sessions` | Validation history for revisions, including status transitions, report JSON, runtime metadata, and start/finish timestamps. | | `custom_dashboard_publications` | The active publication pointer for a dashboard. The table is keyed by `dashboard_id`, so each dashboard has at most one active published revision. | @@ -39,22 +39,27 @@ All dashboard JSON payloads are stored as text and hydrated through `CustomDashb - publish only validated revisions - archive or delete dashboards +Credential IDs mutate only through `updateCredentialBindings`, using compare-and-swap against `credentialBindingRevision`. Generic draft and revision payloads cannot write binding columns. A bound slot must be unbound before its required state, phase, allowed kinds, or required capabilities can change. + Publishing rejects unvalidated, failed, cancelled, or cross-dashboard revisions. Publishing a new validated revision replaces the prior `custom_dashboard_publications` row for the dashboard, preserving the single-active-publication invariant. ## Validation Runtime -`src/services/custom-dashboard-validation-service.ts` owns server-side validation execution. It consumes `CustomDashboardRepository`, `ProjectManagementRepository`, and `SettingsRepository` through the core dependency factory and is exposed to dashboard routes through the dashboard lifecycle dependency object. +`src/services/custom-dashboard-credential-binding-service.ts` owns metadata-only binding orchestration. It loads the project, dashboard or immutable revision, declared slots, secure-backend health, and accessible credential metadata, then delegates every policy decision to `CredentialBroker.assessCompatibility`. Bind/replace/unbind calls use the repository's optimistic mutation and emit correlation-aware audit records containing only project, dashboard, binding revision, slot, credential ID, outcome, and denial reason. + +`src/services/custom-dashboard-validation-service.ts` owns server-side validation execution. It consumes the binding service, `CustomDashboardRepository`, `ProjectManagementRepository`, and `SettingsRepository` through the core dependency factory and is exposed to dashboard routes through the dashboard lifecycle dependency object. Validation flow: -- `startValidation(projectId, dashboardId, revisionId)` creates a validation session, materializes the immutable revision bundle under `.code-ux/runtime/custom-dashboards///workspace`, and writes a generated Vite/Preact harness. +- `startValidation(projectId, dashboardId, revisionId)` first performs a metadata-only revision binding review. A required unbound slot or any missing, revoked, inaccessible, wrong-kind, insufficient-capability, unconfigured, or unavailable-backend binding creates a failed session with slot-specific issues before a workspace or Docker command exists. Optional unbound slots remain valid. +- After that gate passes, validation creates a session, materializes the immutable revision bundle under `.code-ux/runtime/custom-dashboards///workspace`, and writes a generated Vite/Preact harness. - Validation runtime paths are canonicalized under the selected project before filesystem reads or writes, including bundle materialization, logs, and persisted viewer artifacts. - The harness injects a read-only Code UX data bridge containing the revision manifest, source node graph, styleguide, runtime metadata, integrations, and declared `external_api` nodes. - The service runs install/build inside Docker using the resolved `cliWorkflow.containerImage`, then creates and starts a detached serving container on an allocated localhost port. - A validation session is marked `passed` only after install, build, start, and root URL health checks succeed. Build/start/health failures are recorded as failed validation reports with bounded log excerpts. - Runtime metadata persists the workspace path, log path, host port, container id/name, image, validation URL path, commands, latest error/log excerpt, and a browser-ready Vite `dist` artifact for passed revisions so the published viewer can render TSX-based drafts without a live validation container. -Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. Publication accepts either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed, queued, running, cancelled, missing, or cross-revision validation sessions are rejected before the publication pointer changes. +Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. REST and MCP publication re-run metadata-only binding review immediately before calling the repository, then require either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed binding review, queued/running/cancelled validation, missing state, and cross-revision sessions are rejected before the publication pointer changes. ## REST and MCP Surface @@ -64,8 +69,9 @@ Dashboard HTTP routes live in `src/server/custom-dashboard-routes.ts` and are re - dashboard routes get/update/archive a dashboard and create revisions - validation routes start validation, read status/logs, stop/remove validation sessions, and publish revisions - validation proxy routes forward same-origin requests to a running validation host port when the session runtime metadata exposes one +- credential-protected project routes list/review slots and optimistically bind, replace, or unbind credential IDs at `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` -The MCP management surface is `manage_custom_dashboards` in `src/mcp/management/custom-dashboard-actions.ts`. It supports `list`, `get`, `create`, `update`, `create_revision`, `validate_revision`, `validation_status`, `validation_logs`, `publish_revision`, `archive`, and `data_catalog`. `archive` follows the same approval fingerprint flow as other destructive management actions. +Remote HTTP access to the credential-binding route requires the `credential_admin` role, project access, and enabled remote credential management. The MCP management surface is `manage_custom_dashboards` in `src/mcp/management/custom-dashboard-actions.ts`. In addition to the dashboard lifecycle actions, it supports bounded `list_credential_slots`, `bind_credential`, and `unbind_credential`; binding mutations follow the stateful human-approval fingerprint flow and require the owning project ID. Project Manager and dashboard chat prompts steer user-created dashboard requests through this management surface. Agents should gather missing purpose, data-source, styleguide, layout, and publication intent details, then create or update drafts and revisions with complete manifests, file bundles, source node graphs, styleguide tokens, runtime metadata, accessibility notes, and validation expectations. Generated bundles are dependency-free Preact/Tailwind-compatible validation-harness code and must not be written directly into `dashboard/src`. @@ -89,12 +95,12 @@ Draft edits remain persisted bundle text sent back through API calls; generated Published dashboards open through `CustomDashboardViewer`, which resolves the active `publishedRevisionId` from the loaded dashboard detail and renders only when the dashboard status is `published`, the published revision exists, and that revision still has a valid passed validation report. Draft, rejected, archived, unvalidated, and missing-publication states render a local blocked panel with the last validation report and a return-to-editor action rather than executing the bundle. -The viewer uses a sandboxed iframe `srcdoc` document so generated dashboard code never runs inside the main Preact bundle. For validated TSX/Preact revisions, it prefers the persisted Vite `dist` viewer artifact from revision runtime metadata and inlines the artifact's HTML, CSS, and JavaScript into the frame document. Older direct HTML or browser-ready JavaScript entry files still render through the previous entry-file path. The frame receives a frozen `codeUxDataBridge` / `CodeUXCustomDashboard` object and can request only declared source nodes by `id` through `postMessage`. The parent page handles those requests with explicit same-origin API calls for project execution data, project stats, and overview telemetry; integration metadata is limited to non-secret source-node metadata; external API nodes are placeholders and return clear unavailable-source errors. Frame `error` and `unhandledrejection` events are reported back to the viewer and displayed as dashboard-specific failures without breaking the surrounding app shell. +The viewer uses a sandboxed iframe `srcdoc` document so generated dashboard code never runs inside the main Preact bundle. For validated TSX/Preact revisions, it prefers the persisted Vite `dist` viewer artifact from revision runtime metadata and inlines the artifact's HTML, CSS, and JavaScript into the frame document. Older direct HTML or browser-ready JavaScript entry files still render through the previous entry-file path. The frame receives a frozen `codeUxDataBridge` / `CodeUXCustomDashboard` object and can request only declared source nodes by `id` through `postMessage`. Parent and frame handlers verify the expected window source. Generic dashboard/viewer records and frame configuration omit credential-binding IDs; only the dedicated metadata-management responses expose them. The parent page handles source requests with explicit same-origin API calls for project execution data, project stats, and overview telemetry; integration metadata is limited to non-secret source-node metadata; external API nodes are placeholders and return clear unavailable-source errors. Navigation is centralized through `dashboard/src/v2/lib/navigation-items.ts`, so both the kinetic dock and sidebar expose the Dashboards destination with stable labels, tour markers, and route prefetching. ## Docker and Logs -Docker argument construction lives in `src/services/custom-dashboard-docker-plan.ts`. Validation containers use the configured CLI workflow image, bind-mount only the generated workspace/runtime home plus an optional setup script, and do not mount provider credential directories. +Docker argument construction lives in `src/services/custom-dashboard-docker-plan.ts`. Validation containers use the configured CLI workflow image, bind-mount only the generated workspace/runtime home plus an optional setup script, and do not mount provider credential directories. The binding service never calls secret resolution, and credential values and binding IDs are not written to generated files, bridges, validation reports/logs, Docker arguments, iframe configuration, or browser messages. Logs are captured in the validation runtime directory and combined with bounded `docker logs` output through `getValidationLogs(sessionId, tail)`. `stopValidation` removes the detached container while preserving a passed revision report, and `removeValidation` removes the session row after container cleanup. diff --git a/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx b/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx index f19f493a6c..69b20c78f8 100644 --- a/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx +++ b/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx @@ -8,10 +8,10 @@ The shared contracts live in `src/contracts/custom-dashboard-types.ts`. Primary records: -- `CustomDashboardRecord` stores the mutable project-scoped draft state, status, manifest, generated file bundle, source node graph, styleguide JSON, runtime metadata JSON, and active published revision id. -- `CustomDashboardRevisionRecord` stores immutable dashboard bundle snapshots. Manifest, files, source node graph, and styleguide data are copied into each revision so future draft edits do not mutate validation or publication history. +- `CustomDashboardRecord` stores the mutable project-scoped draft state, status, manifest, generated file bundle, source node graph, styleguide JSON, runtime metadata JSON, credential-ID bindings, an optimistic binding revision, and the active published revision id. +- `CustomDashboardRevisionRecord` stores immutable dashboard bundle snapshots. Manifest, files, source node graph, styleguide data, and credential-ID bindings are copied into each revision so future draft edits or rebindings do not mutate validation or publication history. - `CustomDashboardValidationSessionRecord` stores validation attempts for a revision, including queued/building/running/passed/failed/cancelled status, validation report JSON, runtime metadata, and timestamps. -- `CustomDashboardManifest` describes the generated dashboard bundle with schema version, title, entry file, file paths, optional data-source graph, and metadata. +- `CustomDashboardManifest` describes the generated dashboard bundle with schema version, title, entry file, file paths, optional data-source graph, bounded credential-slot declarations, and metadata. A slot declares its build/runtime phase, required state, allowed credential kinds, and required capabilities; it never contains secret material. Dashboard status values are `draft`, `validating`, `validated`, `published`, `rejected`, and `archived`. Validation status values are `queued`, `building`, `running`, `passed`, `failed`, and `cancelled`. @@ -21,8 +21,8 @@ SQLite tables are created in both the initial schema and startup migrations: | Table | Purpose | | --- | --- | -| `custom_dashboards` | Current mutable project-scoped draft state, including manifest JSON, file bundle JSON, source node graph JSON, styleguide JSON, runtime metadata JSON, status, and timestamps. | -| `custom_dashboard_revisions` | Immutable revision snapshots with copied manifest, files, source graph, styleguide, runtime metadata, validation status/report, validated timestamp, and revision number. | +| `custom_dashboards` | Current mutable project-scoped draft state, including manifest JSON, file bundle JSON, source node graph JSON, styleguide JSON, runtime metadata JSON, dedicated credential binding JSON, optimistic binding revision, status, and timestamps. | +| `custom_dashboard_revisions` | Immutable revision snapshots with copied manifest, files, source graph, styleguide, runtime metadata, dedicated credential binding JSON, validation status/report, validated timestamp, and revision number. | | `custom_dashboard_validation_sessions` | Validation history for revisions, including status transitions, report JSON, runtime metadata, and start/finish timestamps. | | `custom_dashboard_publications` | The active publication pointer for a dashboard. The table is keyed by `dashboard_id`, so each dashboard has at most one active published revision. | @@ -39,22 +39,27 @@ All dashboard JSON payloads are stored as text and hydrated through `CustomDashb - publish only validated revisions - archive or delete dashboards +Credential IDs mutate only through `updateCredentialBindings`, using compare-and-swap against `credentialBindingRevision`. Generic draft and revision payloads cannot write binding columns. A bound slot must be unbound before its required state, phase, allowed kinds, or required capabilities can change. + Publishing rejects unvalidated, failed, cancelled, or cross-dashboard revisions. Publishing a new validated revision replaces the prior `custom_dashboard_publications` row for the dashboard, preserving the single-active-publication invariant. ## Validation Runtime -`src/services/custom-dashboard-validation-service.ts` owns server-side validation execution. It consumes `CustomDashboardRepository`, `ProjectManagementRepository`, and `SettingsRepository` through the core dependency factory and is exposed to dashboard routes through the dashboard lifecycle dependency object. +`src/services/custom-dashboard-credential-binding-service.ts` owns metadata-only binding orchestration. It loads the project, dashboard or immutable revision, declared slots, secure-backend health, and accessible credential metadata, then delegates every policy decision to `CredentialBroker.assessCompatibility`. Bind/replace/unbind calls use the repository's optimistic mutation and emit correlation-aware audit records containing only project, dashboard, binding revision, slot, credential ID, outcome, and denial reason. + +`src/services/custom-dashboard-validation-service.ts` owns server-side validation execution. It consumes the binding service, `CustomDashboardRepository`, `ProjectManagementRepository`, and `SettingsRepository` through the core dependency factory and is exposed to dashboard routes through the dashboard lifecycle dependency object. Validation flow: -- `startValidation(projectId, dashboardId, revisionId)` creates a validation session, materializes the immutable revision bundle under `.code-ux/runtime/custom-dashboards///workspace`, and writes a generated Vite/Preact harness. +- `startValidation(projectId, dashboardId, revisionId)` first performs a metadata-only revision binding review. A required unbound slot or any missing, revoked, inaccessible, wrong-kind, insufficient-capability, unconfigured, or unavailable-backend binding creates a failed session with slot-specific issues before a workspace or Docker command exists. Optional unbound slots remain valid. +- After that gate passes, validation creates a session, materializes the immutable revision bundle under `.code-ux/runtime/custom-dashboards///workspace`, and writes a generated Vite/Preact harness. - Validation runtime paths are canonicalized under the selected project before filesystem reads or writes, including bundle materialization, logs, and persisted viewer artifacts. - The harness injects a read-only Code UX data bridge containing the revision manifest, source node graph, styleguide, runtime metadata, integrations, and declared `external_api` nodes. - The service runs install/build inside Docker using the resolved `cliWorkflow.containerImage`, then creates and starts a detached serving container on an allocated localhost port. - A validation session is marked `passed` only after install, build, start, and root URL health checks succeed. Build/start/health failures are recorded as failed validation reports with bounded log excerpts. - Runtime metadata persists the workspace path, log path, host port, container id/name, image, validation URL path, commands, latest error/log excerpt, and a browser-ready Vite `dist` artifact for passed revisions so the published viewer can render TSX-based drafts without a live validation container. -Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. Publication accepts either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed, queued, running, cancelled, missing, or cross-revision validation sessions are rejected before the publication pointer changes. +Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. REST and MCP publication re-run metadata-only binding review immediately before calling the repository, then require either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed binding review, queued/running/cancelled validation, missing state, and cross-revision sessions are rejected before the publication pointer changes. ## REST and MCP Surface @@ -64,8 +69,9 @@ Dashboard HTTP routes live in `src/server/custom-dashboard-routes.ts` and are re - dashboard routes get/update/archive a dashboard and create revisions - validation routes start validation, read status/logs, stop/remove validation sessions, and publish revisions - validation proxy routes forward same-origin requests to a running validation host port when the session runtime metadata exposes one +- credential-protected project routes list/review slots and optimistically bind, replace, or unbind credential IDs at `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` -The MCP management surface is `manage_custom_dashboards` in `src/mcp/management/custom-dashboard-actions.ts`. It supports `list`, `get`, `create`, `update`, `create_revision`, `validate_revision`, `validation_status`, `validation_logs`, `publish_revision`, `archive`, and `data_catalog`. `archive` follows the same approval fingerprint flow as other destructive management actions. +Remote HTTP access to the credential-binding route requires the `credential_admin` role, project access, and enabled remote credential management. The MCP management surface is `manage_custom_dashboards` in `src/mcp/management/custom-dashboard-actions.ts`. In addition to the dashboard lifecycle actions, it supports bounded `list_credential_slots`, `bind_credential`, and `unbind_credential`; binding mutations follow the stateful human-approval fingerprint flow and require the owning project ID. Project Manager and dashboard chat prompts steer user-created dashboard requests through this management surface. Agents should gather missing purpose, data-source, styleguide, layout, and publication intent details, then create or update drafts and revisions with complete manifests, file bundles, source node graphs, styleguide tokens, runtime metadata, accessibility notes, and validation expectations. Generated bundles are dependency-free Preact/Tailwind-compatible validation-harness code and must not be written directly into `dashboard/src`. @@ -89,12 +95,12 @@ Draft edits remain persisted bundle text sent back through API calls; generated Published dashboards open through `CustomDashboardViewer`, which resolves the active `publishedRevisionId` from the loaded dashboard detail and renders only when the dashboard status is `published`, the published revision exists, and that revision still has a valid passed validation report. Draft, rejected, archived, unvalidated, and missing-publication states render a local blocked panel with the last validation report and a return-to-editor action rather than executing the bundle. -The viewer uses a sandboxed iframe `srcdoc` document so generated dashboard code never runs inside the main Preact bundle. For validated TSX/Preact revisions, it prefers the persisted Vite `dist` viewer artifact from revision runtime metadata and inlines the artifact's HTML, CSS, and JavaScript into the frame document. Older direct HTML or browser-ready JavaScript entry files still render through the previous entry-file path. The frame receives a frozen `codeUxDataBridge` / `CodeUXCustomDashboard` object and can request only declared source nodes by `id` through `postMessage`. The parent page handles those requests with explicit same-origin API calls for project execution data, project stats, and overview telemetry; integration metadata is limited to non-secret source-node metadata; external API nodes are placeholders and return clear unavailable-source errors. Frame `error` and `unhandledrejection` events are reported back to the viewer and displayed as dashboard-specific failures without breaking the surrounding app shell. +The viewer uses a sandboxed iframe `srcdoc` document so generated dashboard code never runs inside the main Preact bundle. For validated TSX/Preact revisions, it prefers the persisted Vite `dist` viewer artifact from revision runtime metadata and inlines the artifact's HTML, CSS, and JavaScript into the frame document. Older direct HTML or browser-ready JavaScript entry files still render through the previous entry-file path. The frame receives a frozen `codeUxDataBridge` / `CodeUXCustomDashboard` object and can request only declared source nodes by `id` through `postMessage`. Parent and frame handlers verify the expected window source. Generic dashboard/viewer records and frame configuration omit credential-binding IDs; only the dedicated metadata-management responses expose them. The parent page handles source requests with explicit same-origin API calls for project execution data, project stats, and overview telemetry; integration metadata is limited to non-secret source-node metadata; external API nodes are placeholders and return clear unavailable-source errors. Navigation is centralized through `dashboard/src/v2/lib/navigation-items.ts`, so both the kinetic dock and sidebar expose the Dashboards destination with stable labels, tour markers, and route prefetching. ## Docker and Logs -Docker argument construction lives in `src/services/custom-dashboard-docker-plan.ts`. Validation containers use the configured CLI workflow image, bind-mount only the generated workspace/runtime home plus an optional setup script, and do not mount provider credential directories. +Docker argument construction lives in `src/services/custom-dashboard-docker-plan.ts`. Validation containers use the configured CLI workflow image, bind-mount only the generated workspace/runtime home plus an optional setup script, and do not mount provider credential directories. The binding service never calls secret resolution, and credential values and binding IDs are not written to generated files, bridges, validation reports/logs, Docker arguments, iframe configuration, or browser messages. Logs are captured in the validation runtime directory and combined with bounded `docker logs` output through `getValidationLogs(sessionId, tail)`. `stopValidation` removes the detached container while preserving a passed revision report, and `removeValidation` removes the session row after container cleanup. diff --git a/docs-web/content/docs/developer-http-api.mdx b/docs-web/content/docs/developer-http-api.mdx index 1ead876cae..0552b84230 100644 --- a/docs-web/content/docs/developer-http-api.mdx +++ b/docs-web/content/docs/developer-http-api.mdx @@ -276,13 +276,16 @@ Only instruction markdown is writable, and only compatibility-critical system ad | `POST` | `/api/custom-dashboards/:dashboardId/revisions` | Create an immutable revision. | | `POST` | `/api/custom-dashboards/:dashboardId/revisions/:revisionId/validate` | Start validation for a revision. | | `POST` | `/api/custom-dashboards/:dashboardId/revisions/:revisionId/publish` | Publish a validated revision. | +| `GET` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` | Review draft or revision slots, bindings, backend health, and bounded compatible credential metadata. | +| `PUT` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` | Bind or replace a slot using `slotId`, `credentialId`, and `expectedBindingRevision`. | +| `DELETE` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings/:slotId` | Unbind a slot using `expectedBindingRevision`. | | `GET` | `/api/custom-dashboard-validations/:sessionId` | Get validation session status. | | `GET` | `/api/custom-dashboard-validations/:sessionId/logs` | Get validation logs. | | `POST` | `/api/custom-dashboard-validations/:sessionId/stop` | Stop a validation runtime. | | `DELETE` | `/api/custom-dashboard-validations/:sessionId` | Remove a validation session after cleanup. | | `ALL` | `/api/custom-dashboard-validations/:sessionId/proxy{*rest}` | Proxy same-origin traffic to the validation runtime host port. | -Publishing rejects failed, queued, running, cancelled, missing, or cross-revision validation sessions and keeps the previously published revision unchanged. +Authenticated remote access to binding routes requires the credential-administrator role, project access, and enabled remote credential management. Binding responses contain credential IDs and metadata only; generic dashboard responses omit them. Validation and publication fail closed for required or incompatible bindings without resolving secret values, and publication keeps the previously published revision unchanged on any denial. --- diff --git a/docs-web/content/docs/developer-management-actions.mdx b/docs-web/content/docs/developer-management-actions.mdx index f22a9d9a18..17c4ec144e 100644 --- a/docs-web/content/docs/developer-management-actions.mdx +++ b/docs-web/content/docs/developer-management-actions.mdx @@ -303,8 +303,11 @@ Project-scoped generated dashboards, immutable revisions, detached validation se | `publish_revision` | – | `dashboardId`, `revisionId`, optional `validationSessionId` | Publish only a passed revision with a valid report. | | `archive` | ✅ | `dashboardId` | Clear active publication and mark the dashboard archived. | | `data_catalog` | – | `projectId` | Return dashboard summaries and declared source nodes. | +| `list_credential_slots` | – | `projectId`, `dashboardId` | Return a bounded metadata-only review of declared slots, bindings, backend health, and compatible candidates. Optional `revisionId` reviews an immutable revision. | +| `bind_credential` | ✅ | `projectId`, `dashboardId`, `slotId`, `credentialId`, `expectedBindingRevision` | Bind or replace a slot by credential ID after the stateful approval handshake. | +| `unbind_credential` | ✅ | `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision` | Remove a slot binding after the stateful approval handshake. | -Publication rejects failed, queued, running, cancelled, missing, or cross-revision validation sessions before the active publication pointer changes. +Credential actions reject secret-bearing or undeclared fields and never resolve plaintext. Validation and publication review required and bound slots against backend health, project access, status, kind, and required capabilities; a denial blocks the operation before the active publication pointer changes. Generic custom-dashboard responses omit binding IDs. --- diff --git a/docs-web/content/docs/developer-mcp-tools.mdx b/docs-web/content/docs/developer-mcp-tools.mdx index ee82acd1c2..3683f6abf8 100644 --- a/docs-web/content/docs/developer-mcp-tools.mdx +++ b/docs-web/content/docs/developer-mcp-tools.mdx @@ -63,7 +63,7 @@ action-specific fields, and an optional `approval` object for destructive action | `search_skills` | agents & memory | Semantic retrieval over persistent project skills, optionally scoped to an agent or storage. | | `manage_settings` | platform | Get/resolve/patch/replace/reset system, project, and sprint settings. | | `manage_preview` | platform | Manage sprint preview containers (start/stop/rebuild, logs, scripts). | -| `manage_custom_dashboards` | platform | Manage project custom dashboard drafts, revisions, detached validation sessions, publication, archiving, and data catalog lookup. | +| `manage_custom_dashboards` | platform | Manage project custom dashboard drafts, metadata-only credential bindings, revisions, detached validation sessions, publication, archiving, and data catalog lookup. | | `manage_chat_providers` | platform | Manage external chat provider setup definitions, connections, bindings, and outbound delivery state. | | `manage_telemetry` | platform | Read execution snapshots, invocations, sprint runs, and dispatches. | @@ -132,7 +132,7 @@ Clarification states are `pending`, `replied`, `expired`, and `cancelled`. Repea | `manage_skills` | `authoring_prompt`, `list_storages`, `get_storage`, `create_storage`, `update_storage`, `delete_storage`, `reset_storage`, `list_agent_storages`, `attach_storage`, `detach_storage`, `list_skills`, `get_skill`, `create_skill`, `update_skill`, `delete_skill`, `import_markdown`, `export_markdown` | | `manage_settings` | `get_system`, `get_project_override`, `resolve_project_effective`, `get_sprint_override`, `resolve_sprint_effective`, `replace_system_settings`, `patch_system_setting`, `replace_project_settings`, `patch_project_setting`, `reset_project_settings`, `replace_sprint_settings`, `patch_sprint_setting`, `reset_sprint_settings`, `export_settings_bundle`, `apply_settings_bundle` | | `manage_preview` | `list_sessions`, `start_session`, `stop_session`, `rebuild_session`, `remove_session`, `get_logs`, `get_url`, `get_script`, `update_script` | -| `manage_custom_dashboards` | `list`, `get`, `create`, `update`, `create_revision`, `validate_revision`, `validation_status`, `validation_logs`, `publish_revision`, `archive`, `data_catalog` | +| `manage_custom_dashboards` | `list`, `get`, `create`, `update`, `create_revision`, `validate_revision`, `validation_status`, `validation_logs`, `publish_revision`, `archive`, `data_catalog`, `list_credential_slots`, `bind_credential`, `unbind_credential` | | `manage_chat_providers` | `list_provider_definitions`, `list_connections`, `get_connection`, `create_connection`, `update_connection`, `delete_connection`, `list_channel_bindings`, `create_channel_binding`, `update_channel_binding`, `delete_channel_binding`, `list_outbound_deliveries` | | `manage_telemetry` | `get_project_stats_snapshot`, `get_project_execution_snapshot`, `list_execution_invocations`, `list_execution_invocation_messages`, `list_sprint_runs`, `list_task_dispatches` | @@ -140,6 +140,8 @@ For `manage_projects` setup, clients may send setup options either as `setup.opt For the full per-action payloads and return shapes, see [Management actions](/docs/developer-management-actions). +Custom-dashboard credential actions are project-scoped and metadata-only. `list_credential_slots` returns bounded compatible credential metadata; `bind_credential` and `unbind_credential` require `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision`, and the stateful approval handshake, with `credentialId` added for bind/replace. They reject secret-bearing or undeclared fields. Validation and publication fail closed on required or incompatible bindings without resolving plaintext, and generic custom-dashboard MCP responses omit binding IDs. + ### Background sprint planning `manage_sprints` with `action: "plan"` returns a `status: "started"` acknowledgement immediately after synchronous precondition validation, while planning continues server-side. The stable `result` fields are `status`, `message`, `projectId`, and `sprintId`; additive `planningGuidance` supplies status, terminality, invocation/start identity, calculated duration and ETA, next-check timing, one-minute recheck cadence, sample/fallback metadata, an actionable message, and optional failure evidence. See [Management actions](/docs/developer-management-actions#sprints) for the field-by-field contract. The acknowledgement does not mean generated tasks already exist or that optional auto-start has completed. diff --git a/docs-web/content/docs/operations-credential-security.mdx b/docs-web/content/docs/operations-credential-security.mdx index 87b8cc36ec..f72c174dff 100644 --- a/docs-web/content/docs/operations-credential-security.mdx +++ b/docs-web/content/docs/operations-credential-security.mdx @@ -37,6 +37,8 @@ Back up root keys separately from `app.db`; the database alone cannot recover cr Lifecycle success and denial audits carry correlation IDs, credential IDs, and policy metadata only. Validation records `valid`, `invalid`, or `unavailable` without exposing tested values or cryptographic internals. +Custom dashboards use a stricter metadata-only consumer boundary. Dedicated slot declarations define allowed kinds and required capabilities, while separate draft and immutable-revision binding columns store credential IDs. Binding review delegates to the broker's compatibility assessment and never resolves plaintext. Required or invalid bindings stop validation before workspace creation and are rechecked before publication. Credential values and binding IDs are excluded from generated dashboard artifacts, Docker configuration, validation output, generic REST/MCP responses, and iframe messages; only the dedicated binding-management response may expose IDs with non-secret metadata. + Legacy global records use their first valid allowlisted project as the migrated management owner; verify that owner before expanding an old global allowlist. ## Dashboard API diff --git a/docs-web/content/docs/user-dashboard-custom-dashboards.mdx b/docs-web/content/docs/user-dashboard-custom-dashboards.mdx index e25cefb67a..42fa2e5174 100644 --- a/docs-web/content/docs/user-dashboard-custom-dashboards.mdx +++ b/docs-web/content/docs/user-dashboard-custom-dashboards.mdx @@ -7,11 +7,12 @@ Custom dashboards are project-scoped dashboard apps generated and revised by age 1. Ask the Project Manager for the dashboard you want. Include the purpose, target audience, data sources, layout preferences, review criteria, and whether it should be published after validation. 2. Review the draft at `/custom-dashboards`. Drafts expose manifest JSON, generated file bundle content, source-node graph JSON, styleguide JSON, and data catalog selections. 3. Ask for changes or edit the draft before creating a revision. Draft edits do not change previous revisions or the currently published dashboard. -4. Create a revision when the draft is ready. A revision snapshots the current manifest, files, source graph, styleguide, and runtime metadata. -5. Run detached validation. Code UX builds the revision in Docker, captures the browser-ready Vite artifact, starts a detached preview container, and health-checks the root URL. -6. Inspect validation status, logs, and the proxied preview link. Validation passes only after install, build, artifact capture, container start, and root health checks succeed. -7. Publish the validated revision. Publication is blocked unless the revision has a passed validation report. -8. Roll back by publishing an earlier passed revision, or archive the dashboard to clear its active publication while preserving history. +4. If the manifest declares credential slots, review them through the credential-binding management surface. Bind each required slot to a compatible credential ID; no secret value is entered into the dashboard draft or generated code. +5. Create a revision when the draft is ready. A revision snapshots the current manifest, files, source graph, styleguide, runtime metadata, and credential-ID bindings. +6. Run detached validation. Code UX reviews bindings before it builds the revision in Docker, captures the browser-ready Vite artifact, starts a detached preview container, and health-checks the root URL. +7. Inspect validation status, logs, and the proxied preview link. Validation passes only after credential policy, install, build, artifact capture, container start, and root health checks succeed. +8. Publish the validated revision. Publication rechecks credential metadata and remains blocked unless the revision has a passed validation report. +9. Roll back by publishing an earlier passed revision, or archive the dashboard to clear its active publication while preserving history. If validation fails, use the report and logs to create a new revision. Code UX rejects failed, queued, running, cancelled, missing, or mismatched validation sessions before publication state changes. When a dashboard is already published, validating later drafts keeps the active published dashboard open, and validation sessions for the active published revision do not replace its published validation snapshot. @@ -31,7 +32,7 @@ Generated dashboards should handle unavailable-source errors visibly. External A ## Agent and API Notes -Project Manager agents use the `manage_custom_dashboards` MCP tool to create drafts, create revisions, validate revisions, inspect logs, publish passed revisions, archive dashboards, and read the data catalog. +Project Manager agents use the `manage_custom_dashboards` MCP tool to create drafts, list credential slots, bind or unbind credential IDs, create revisions, validate revisions, inspect logs, publish passed revisions, archive dashboards, and read the data catalog. Credential mutations require the normal stateful human-approval handshake and an optimistic `expectedBindingRevision`. The same workflow is available through the dashboard REST API: @@ -41,10 +42,17 @@ The same workflow is available through the dashboard REST API: - `POST /api/custom-dashboards/:dashboardId/revisions` - `POST /api/custom-dashboards/:dashboardId/revisions/:revisionId/validate` - `POST /api/custom-dashboards/:dashboardId/revisions/:revisionId/publish` +- `GET /api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` +- `PUT /api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` +- `DELETE /api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings/:slotId` - `GET /api/custom-dashboard-validations/:sessionId` - `GET /api/custom-dashboard-validations/:sessionId/logs` - `POST /api/custom-dashboard-validations/:sessionId/stop` - `DELETE /api/custom-dashboard-validations/:sessionId` - `ALL /api/custom-dashboard-validations/:sessionId/proxy{*rest}` -Published dashboards render inside a sandboxed iframe. For TSX/Preact drafts such as the default `src/dashboard.tsx` bundle, the viewer uses the persisted validation artifact instead of the source entry file, so it can open after publication even when the detached validation preview is gone. The frame can request only declared source nodes through the Code UX bridge, and the parent dashboard returns data through same-origin API calls. +Remote callers to credential-binding routes require the credential-administrator role, project access, and enabled remote credential management. Required missing bindings and bound credentials that are revoked, inaccessible, unconfigured, wrong-kind, missing capabilities, or blocked by unavailable key custody fail before workspace creation and are rechecked before publication. Optional unbound slots remain valid. + +Custom-dashboard binding is metadata-only: no secret is resolved, and credential values and binding IDs are excluded from generated files, bridges, Docker configuration, validation output, generic REST/MCP responses, iframe configuration, and browser messages. Dedicated binding-management responses may return credential IDs and non-secret metadata so operators and agents can select them. + +Published dashboards render inside a sandboxed iframe. For TSX/Preact drafts such as the default `src/dashboard.tsx` bundle, the viewer uses the persisted validation artifact instead of the source entry file, so it can open after publication even when the detached validation preview is gone. The frame can request only declared source nodes through the Code UX bridge, parent and frame handlers verify the expected window source, and the parent dashboard returns data through same-origin API calls. diff --git a/docs-web/developer/http-api.md b/docs-web/developer/http-api.md index f4e1a6adc9..3ccde70023 100644 --- a/docs-web/developer/http-api.md +++ b/docs-web/developer/http-api.md @@ -276,13 +276,16 @@ Only instruction markdown is writable, and only compatibility-critical system ad | `POST` | `/api/custom-dashboards/:dashboardId/revisions` | Create an immutable revision. | | `POST` | `/api/custom-dashboards/:dashboardId/revisions/:revisionId/validate` | Start validation for a revision. | | `POST` | `/api/custom-dashboards/:dashboardId/revisions/:revisionId/publish` | Publish a validated revision. | +| `GET` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` | Review draft or revision slots, bindings, backend health, and bounded compatible credential metadata. | +| `PUT` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` | Bind or replace a slot using `slotId`, `credentialId`, and `expectedBindingRevision`. | +| `DELETE` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings/:slotId` | Unbind a slot using `expectedBindingRevision`. | | `GET` | `/api/custom-dashboard-validations/:sessionId` | Get validation session status. | | `GET` | `/api/custom-dashboard-validations/:sessionId/logs` | Get validation logs. | | `POST` | `/api/custom-dashboard-validations/:sessionId/stop` | Stop a validation runtime. | | `DELETE` | `/api/custom-dashboard-validations/:sessionId` | Remove a validation session after cleanup. | | `ALL` | `/api/custom-dashboard-validations/:sessionId/proxy{*rest}` | Proxy same-origin traffic to the validation runtime host port. | -Publishing rejects failed, queued, running, cancelled, missing, or cross-revision validation sessions and keeps the previously published revision unchanged. +Authenticated remote access to binding routes requires the credential-administrator role, project access, and enabled remote credential management. Binding responses contain credential IDs and metadata only; generic dashboard responses omit them. Validation and publication fail closed for required or incompatible bindings without resolving secret values, and publication keeps the previously published revision unchanged on any denial. --- diff --git a/docs-web/developer/management-actions.md b/docs-web/developer/management-actions.md index 25dc4e9a02..219a025129 100644 --- a/docs-web/developer/management-actions.md +++ b/docs-web/developer/management-actions.md @@ -303,8 +303,11 @@ Project-scoped generated dashboards, immutable revisions, detached validation se | `publish_revision` | – | `dashboardId`, `revisionId`, optional `validationSessionId` | Publish only a passed revision with a valid report. | | `archive` | ✅ | `dashboardId` | Clear active publication and mark the dashboard archived. | | `data_catalog` | – | `projectId` | Return dashboard summaries and declared source nodes. | +| `list_credential_slots` | – | `projectId`, `dashboardId` | Return a bounded metadata-only review of declared slots, bindings, backend health, and compatible candidates. Optional `revisionId` reviews an immutable revision. | +| `bind_credential` | ✅ | `projectId`, `dashboardId`, `slotId`, `credentialId`, `expectedBindingRevision` | Bind or replace a slot by credential ID after the stateful approval handshake. | +| `unbind_credential` | ✅ | `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision` | Remove a slot binding after the stateful approval handshake. | -Publication rejects failed, queued, running, cancelled, missing, or cross-revision validation sessions before the active publication pointer changes. +Credential actions reject secret-bearing or undeclared fields and never resolve plaintext. Validation and publication review required and bound slots against backend health, project access, status, kind, and required capabilities; a denial blocks the operation before the active publication pointer changes. Generic custom-dashboard responses omit binding IDs. --- diff --git a/docs-web/developer/mcp-tools.md b/docs-web/developer/mcp-tools.md index beeae85e68..3da0396f42 100644 --- a/docs-web/developer/mcp-tools.md +++ b/docs-web/developer/mcp-tools.md @@ -63,7 +63,7 @@ action-specific fields, and an optional `approval` object for destructive action | `search_skills` | agents & memory | Semantic retrieval over persistent project skills, optionally scoped to an agent or storage. | | `manage_settings` | platform | Get/resolve/patch/replace/reset system, project, and sprint settings. | | `manage_preview` | platform | Manage sprint preview containers (start/stop/rebuild, logs, scripts). | -| `manage_custom_dashboards` | platform | Manage project custom dashboard drafts, revisions, detached validation sessions, publication, archiving, and data catalog lookup. | +| `manage_custom_dashboards` | platform | Manage project custom dashboard drafts, metadata-only credential bindings, revisions, detached validation sessions, publication, archiving, and data catalog lookup. | | `manage_chat_providers` | platform | Manage external chat provider setup definitions, connections, bindings, and outbound delivery state. | | `manage_telemetry` | platform | Read execution snapshots, invocations, sprint runs, and dispatches. | @@ -132,7 +132,7 @@ Clarification states are `pending`, `replied`, `expired`, and `cancelled`. Repea | `manage_skills` | `authoring_prompt`, `list_storages`, `get_storage`, `create_storage`, `update_storage`, `delete_storage`, `reset_storage`, `list_agent_storages`, `attach_storage`, `detach_storage`, `list_skills`, `get_skill`, `create_skill`, `update_skill`, `delete_skill`, `import_markdown`, `export_markdown` | | `manage_settings` | `get_system`, `get_project_override`, `resolve_project_effective`, `get_sprint_override`, `resolve_sprint_effective`, `replace_system_settings`, `patch_system_setting`, `replace_project_settings`, `patch_project_setting`, `reset_project_settings`, `replace_sprint_settings`, `patch_sprint_setting`, `reset_sprint_settings`, `export_settings_bundle`, `apply_settings_bundle` | | `manage_preview` | `list_sessions`, `start_session`, `stop_session`, `rebuild_session`, `remove_session`, `get_logs`, `get_url`, `get_script`, `update_script` | -| `manage_custom_dashboards` | `list`, `get`, `create`, `update`, `create_revision`, `validate_revision`, `validation_status`, `validation_logs`, `publish_revision`, `archive`, `data_catalog` | +| `manage_custom_dashboards` | `list`, `get`, `create`, `update`, `create_revision`, `validate_revision`, `validation_status`, `validation_logs`, `publish_revision`, `archive`, `data_catalog`, `list_credential_slots`, `bind_credential`, `unbind_credential` | | `manage_chat_providers` | `list_provider_definitions`, `list_connections`, `get_connection`, `create_connection`, `update_connection`, `delete_connection`, `list_channel_bindings`, `create_channel_binding`, `update_channel_binding`, `delete_channel_binding`, `list_outbound_deliveries` | | `manage_telemetry` | `get_project_stats_snapshot`, `get_project_execution_snapshot`, `list_execution_invocations`, `list_execution_invocation_messages`, `list_sprint_runs`, `list_task_dispatches` | @@ -140,6 +140,8 @@ For `manage_projects` setup, clients may send setup options either as `setup.opt For the full per-action payloads and return shapes, see [Management actions](./management-actions.md). +Custom-dashboard credential actions are project-scoped and metadata-only. `list_credential_slots` returns bounded compatible credential metadata; `bind_credential` and `unbind_credential` require `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision`, and the stateful approval handshake, with `credentialId` added for bind/replace. They reject secret-bearing or undeclared fields. Validation and publication fail closed on required or incompatible bindings without resolving plaintext, and generic custom-dashboard MCP responses omit binding IDs. + ### Background sprint planning `manage_sprints` with `action: "plan"` returns a `status: "started"` acknowledgement immediately after synchronous precondition validation, while planning continues server-side. The stable `result` fields are `status`, `message`, `projectId`, and `sprintId`; additive `planningGuidance` supplies status, terminality, invocation/start identity, calculated duration and ETA, next-check timing, one-minute recheck cadence, sample/fallback metadata, an actionable message, and optional failure evidence. See [Management actions](./management-actions.md#sprints) for the field-by-field contract. The acknowledgement does not mean generated tasks already exist or that optional auto-start has completed. diff --git a/docs-web/operations/credential-security.md b/docs-web/operations/credential-security.md index 87b8cc36ec..f72c174dff 100644 --- a/docs-web/operations/credential-security.md +++ b/docs-web/operations/credential-security.md @@ -37,6 +37,8 @@ Back up root keys separately from `app.db`; the database alone cannot recover cr Lifecycle success and denial audits carry correlation IDs, credential IDs, and policy metadata only. Validation records `valid`, `invalid`, or `unavailable` without exposing tested values or cryptographic internals. +Custom dashboards use a stricter metadata-only consumer boundary. Dedicated slot declarations define allowed kinds and required capabilities, while separate draft and immutable-revision binding columns store credential IDs. Binding review delegates to the broker's compatibility assessment and never resolves plaintext. Required or invalid bindings stop validation before workspace creation and are rechecked before publication. Credential values and binding IDs are excluded from generated dashboard artifacts, Docker configuration, validation output, generic REST/MCP responses, and iframe messages; only the dedicated binding-management response may expose IDs with non-secret metadata. + Legacy global records use their first valid allowlisted project as the migrated management owner; verify that owner before expanding an old global allowlist. ## Dashboard API diff --git a/docs-web/user/dashboard/custom-dashboards.md b/docs-web/user/dashboard/custom-dashboards.md index e25cefb67a..42fa2e5174 100644 --- a/docs-web/user/dashboard/custom-dashboards.md +++ b/docs-web/user/dashboard/custom-dashboards.md @@ -7,11 +7,12 @@ Custom dashboards are project-scoped dashboard apps generated and revised by age 1. Ask the Project Manager for the dashboard you want. Include the purpose, target audience, data sources, layout preferences, review criteria, and whether it should be published after validation. 2. Review the draft at `/custom-dashboards`. Drafts expose manifest JSON, generated file bundle content, source-node graph JSON, styleguide JSON, and data catalog selections. 3. Ask for changes or edit the draft before creating a revision. Draft edits do not change previous revisions or the currently published dashboard. -4. Create a revision when the draft is ready. A revision snapshots the current manifest, files, source graph, styleguide, and runtime metadata. -5. Run detached validation. Code UX builds the revision in Docker, captures the browser-ready Vite artifact, starts a detached preview container, and health-checks the root URL. -6. Inspect validation status, logs, and the proxied preview link. Validation passes only after install, build, artifact capture, container start, and root health checks succeed. -7. Publish the validated revision. Publication is blocked unless the revision has a passed validation report. -8. Roll back by publishing an earlier passed revision, or archive the dashboard to clear its active publication while preserving history. +4. If the manifest declares credential slots, review them through the credential-binding management surface. Bind each required slot to a compatible credential ID; no secret value is entered into the dashboard draft or generated code. +5. Create a revision when the draft is ready. A revision snapshots the current manifest, files, source graph, styleguide, runtime metadata, and credential-ID bindings. +6. Run detached validation. Code UX reviews bindings before it builds the revision in Docker, captures the browser-ready Vite artifact, starts a detached preview container, and health-checks the root URL. +7. Inspect validation status, logs, and the proxied preview link. Validation passes only after credential policy, install, build, artifact capture, container start, and root health checks succeed. +8. Publish the validated revision. Publication rechecks credential metadata and remains blocked unless the revision has a passed validation report. +9. Roll back by publishing an earlier passed revision, or archive the dashboard to clear its active publication while preserving history. If validation fails, use the report and logs to create a new revision. Code UX rejects failed, queued, running, cancelled, missing, or mismatched validation sessions before publication state changes. When a dashboard is already published, validating later drafts keeps the active published dashboard open, and validation sessions for the active published revision do not replace its published validation snapshot. @@ -31,7 +32,7 @@ Generated dashboards should handle unavailable-source errors visibly. External A ## Agent and API Notes -Project Manager agents use the `manage_custom_dashboards` MCP tool to create drafts, create revisions, validate revisions, inspect logs, publish passed revisions, archive dashboards, and read the data catalog. +Project Manager agents use the `manage_custom_dashboards` MCP tool to create drafts, list credential slots, bind or unbind credential IDs, create revisions, validate revisions, inspect logs, publish passed revisions, archive dashboards, and read the data catalog. Credential mutations require the normal stateful human-approval handshake and an optimistic `expectedBindingRevision`. The same workflow is available through the dashboard REST API: @@ -41,10 +42,17 @@ The same workflow is available through the dashboard REST API: - `POST /api/custom-dashboards/:dashboardId/revisions` - `POST /api/custom-dashboards/:dashboardId/revisions/:revisionId/validate` - `POST /api/custom-dashboards/:dashboardId/revisions/:revisionId/publish` +- `GET /api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` +- `PUT /api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` +- `DELETE /api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings/:slotId` - `GET /api/custom-dashboard-validations/:sessionId` - `GET /api/custom-dashboard-validations/:sessionId/logs` - `POST /api/custom-dashboard-validations/:sessionId/stop` - `DELETE /api/custom-dashboard-validations/:sessionId` - `ALL /api/custom-dashboard-validations/:sessionId/proxy{*rest}` -Published dashboards render inside a sandboxed iframe. For TSX/Preact drafts such as the default `src/dashboard.tsx` bundle, the viewer uses the persisted validation artifact instead of the source entry file, so it can open after publication even when the detached validation preview is gone. The frame can request only declared source nodes through the Code UX bridge, and the parent dashboard returns data through same-origin API calls. +Remote callers to credential-binding routes require the credential-administrator role, project access, and enabled remote credential management. Required missing bindings and bound credentials that are revoked, inaccessible, unconfigured, wrong-kind, missing capabilities, or blocked by unavailable key custody fail before workspace creation and are rechecked before publication. Optional unbound slots remain valid. + +Custom-dashboard binding is metadata-only: no secret is resolved, and credential values and binding IDs are excluded from generated files, bridges, Docker configuration, validation output, generic REST/MCP responses, iframe configuration, and browser messages. Dedicated binding-management responses may return credential IDs and non-secret metadata so operators and agents can select them. + +Published dashboards render inside a sandboxed iframe. For TSX/Preact drafts such as the default `src/dashboard.tsx` bundle, the viewer uses the persisted validation artifact instead of the source entry file, so it can open after publication even when the detached validation preview is gone. The frame can request only declared source nodes through the Code UX bridge, parent and frame handlers verify the expected window source, and the parent dashboard returns data through same-origin API calls. diff --git a/docs/architecture/custom-dashboard-foundation.md b/docs/architecture/custom-dashboard-foundation.md index f19f493a6c..69b20c78f8 100644 --- a/docs/architecture/custom-dashboard-foundation.md +++ b/docs/architecture/custom-dashboard-foundation.md @@ -8,10 +8,10 @@ The shared contracts live in `src/contracts/custom-dashboard-types.ts`. Primary records: -- `CustomDashboardRecord` stores the mutable project-scoped draft state, status, manifest, generated file bundle, source node graph, styleguide JSON, runtime metadata JSON, and active published revision id. -- `CustomDashboardRevisionRecord` stores immutable dashboard bundle snapshots. Manifest, files, source node graph, and styleguide data are copied into each revision so future draft edits do not mutate validation or publication history. +- `CustomDashboardRecord` stores the mutable project-scoped draft state, status, manifest, generated file bundle, source node graph, styleguide JSON, runtime metadata JSON, credential-ID bindings, an optimistic binding revision, and the active published revision id. +- `CustomDashboardRevisionRecord` stores immutable dashboard bundle snapshots. Manifest, files, source node graph, styleguide data, and credential-ID bindings are copied into each revision so future draft edits or rebindings do not mutate validation or publication history. - `CustomDashboardValidationSessionRecord` stores validation attempts for a revision, including queued/building/running/passed/failed/cancelled status, validation report JSON, runtime metadata, and timestamps. -- `CustomDashboardManifest` describes the generated dashboard bundle with schema version, title, entry file, file paths, optional data-source graph, and metadata. +- `CustomDashboardManifest` describes the generated dashboard bundle with schema version, title, entry file, file paths, optional data-source graph, bounded credential-slot declarations, and metadata. A slot declares its build/runtime phase, required state, allowed credential kinds, and required capabilities; it never contains secret material. Dashboard status values are `draft`, `validating`, `validated`, `published`, `rejected`, and `archived`. Validation status values are `queued`, `building`, `running`, `passed`, `failed`, and `cancelled`. @@ -21,8 +21,8 @@ SQLite tables are created in both the initial schema and startup migrations: | Table | Purpose | | --- | --- | -| `custom_dashboards` | Current mutable project-scoped draft state, including manifest JSON, file bundle JSON, source node graph JSON, styleguide JSON, runtime metadata JSON, status, and timestamps. | -| `custom_dashboard_revisions` | Immutable revision snapshots with copied manifest, files, source graph, styleguide, runtime metadata, validation status/report, validated timestamp, and revision number. | +| `custom_dashboards` | Current mutable project-scoped draft state, including manifest JSON, file bundle JSON, source node graph JSON, styleguide JSON, runtime metadata JSON, dedicated credential binding JSON, optimistic binding revision, status, and timestamps. | +| `custom_dashboard_revisions` | Immutable revision snapshots with copied manifest, files, source graph, styleguide, runtime metadata, dedicated credential binding JSON, validation status/report, validated timestamp, and revision number. | | `custom_dashboard_validation_sessions` | Validation history for revisions, including status transitions, report JSON, runtime metadata, and start/finish timestamps. | | `custom_dashboard_publications` | The active publication pointer for a dashboard. The table is keyed by `dashboard_id`, so each dashboard has at most one active published revision. | @@ -39,22 +39,27 @@ All dashboard JSON payloads are stored as text and hydrated through `CustomDashb - publish only validated revisions - archive or delete dashboards +Credential IDs mutate only through `updateCredentialBindings`, using compare-and-swap against `credentialBindingRevision`. Generic draft and revision payloads cannot write binding columns. A bound slot must be unbound before its required state, phase, allowed kinds, or required capabilities can change. + Publishing rejects unvalidated, failed, cancelled, or cross-dashboard revisions. Publishing a new validated revision replaces the prior `custom_dashboard_publications` row for the dashboard, preserving the single-active-publication invariant. ## Validation Runtime -`src/services/custom-dashboard-validation-service.ts` owns server-side validation execution. It consumes `CustomDashboardRepository`, `ProjectManagementRepository`, and `SettingsRepository` through the core dependency factory and is exposed to dashboard routes through the dashboard lifecycle dependency object. +`src/services/custom-dashboard-credential-binding-service.ts` owns metadata-only binding orchestration. It loads the project, dashboard or immutable revision, declared slots, secure-backend health, and accessible credential metadata, then delegates every policy decision to `CredentialBroker.assessCompatibility`. Bind/replace/unbind calls use the repository's optimistic mutation and emit correlation-aware audit records containing only project, dashboard, binding revision, slot, credential ID, outcome, and denial reason. + +`src/services/custom-dashboard-validation-service.ts` owns server-side validation execution. It consumes the binding service, `CustomDashboardRepository`, `ProjectManagementRepository`, and `SettingsRepository` through the core dependency factory and is exposed to dashboard routes through the dashboard lifecycle dependency object. Validation flow: -- `startValidation(projectId, dashboardId, revisionId)` creates a validation session, materializes the immutable revision bundle under `.code-ux/runtime/custom-dashboards///workspace`, and writes a generated Vite/Preact harness. +- `startValidation(projectId, dashboardId, revisionId)` first performs a metadata-only revision binding review. A required unbound slot or any missing, revoked, inaccessible, wrong-kind, insufficient-capability, unconfigured, or unavailable-backend binding creates a failed session with slot-specific issues before a workspace or Docker command exists. Optional unbound slots remain valid. +- After that gate passes, validation creates a session, materializes the immutable revision bundle under `.code-ux/runtime/custom-dashboards///workspace`, and writes a generated Vite/Preact harness. - Validation runtime paths are canonicalized under the selected project before filesystem reads or writes, including bundle materialization, logs, and persisted viewer artifacts. - The harness injects a read-only Code UX data bridge containing the revision manifest, source node graph, styleguide, runtime metadata, integrations, and declared `external_api` nodes. - The service runs install/build inside Docker using the resolved `cliWorkflow.containerImage`, then creates and starts a detached serving container on an allocated localhost port. - A validation session is marked `passed` only after install, build, start, and root URL health checks succeed. Build/start/health failures are recorded as failed validation reports with bounded log excerpts. - Runtime metadata persists the workspace path, log path, host port, container id/name, image, validation URL path, commands, latest error/log excerpt, and a browser-ready Vite `dist` artifact for passed revisions so the published viewer can render TSX-based drafts without a live validation container. -Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. Publication accepts either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed, queued, running, cancelled, missing, or cross-revision validation sessions are rejected before the publication pointer changes. +Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. REST and MCP publication re-run metadata-only binding review immediately before calling the repository, then require either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed binding review, queued/running/cancelled validation, missing state, and cross-revision sessions are rejected before the publication pointer changes. ## REST and MCP Surface @@ -64,8 +69,9 @@ Dashboard HTTP routes live in `src/server/custom-dashboard-routes.ts` and are re - dashboard routes get/update/archive a dashboard and create revisions - validation routes start validation, read status/logs, stop/remove validation sessions, and publish revisions - validation proxy routes forward same-origin requests to a running validation host port when the session runtime metadata exposes one +- credential-protected project routes list/review slots and optimistically bind, replace, or unbind credential IDs at `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` -The MCP management surface is `manage_custom_dashboards` in `src/mcp/management/custom-dashboard-actions.ts`. It supports `list`, `get`, `create`, `update`, `create_revision`, `validate_revision`, `validation_status`, `validation_logs`, `publish_revision`, `archive`, and `data_catalog`. `archive` follows the same approval fingerprint flow as other destructive management actions. +Remote HTTP access to the credential-binding route requires the `credential_admin` role, project access, and enabled remote credential management. The MCP management surface is `manage_custom_dashboards` in `src/mcp/management/custom-dashboard-actions.ts`. In addition to the dashboard lifecycle actions, it supports bounded `list_credential_slots`, `bind_credential`, and `unbind_credential`; binding mutations follow the stateful human-approval fingerprint flow and require the owning project ID. Project Manager and dashboard chat prompts steer user-created dashboard requests through this management surface. Agents should gather missing purpose, data-source, styleguide, layout, and publication intent details, then create or update drafts and revisions with complete manifests, file bundles, source node graphs, styleguide tokens, runtime metadata, accessibility notes, and validation expectations. Generated bundles are dependency-free Preact/Tailwind-compatible validation-harness code and must not be written directly into `dashboard/src`. @@ -89,12 +95,12 @@ Draft edits remain persisted bundle text sent back through API calls; generated Published dashboards open through `CustomDashboardViewer`, which resolves the active `publishedRevisionId` from the loaded dashboard detail and renders only when the dashboard status is `published`, the published revision exists, and that revision still has a valid passed validation report. Draft, rejected, archived, unvalidated, and missing-publication states render a local blocked panel with the last validation report and a return-to-editor action rather than executing the bundle. -The viewer uses a sandboxed iframe `srcdoc` document so generated dashboard code never runs inside the main Preact bundle. For validated TSX/Preact revisions, it prefers the persisted Vite `dist` viewer artifact from revision runtime metadata and inlines the artifact's HTML, CSS, and JavaScript into the frame document. Older direct HTML or browser-ready JavaScript entry files still render through the previous entry-file path. The frame receives a frozen `codeUxDataBridge` / `CodeUXCustomDashboard` object and can request only declared source nodes by `id` through `postMessage`. The parent page handles those requests with explicit same-origin API calls for project execution data, project stats, and overview telemetry; integration metadata is limited to non-secret source-node metadata; external API nodes are placeholders and return clear unavailable-source errors. Frame `error` and `unhandledrejection` events are reported back to the viewer and displayed as dashboard-specific failures without breaking the surrounding app shell. +The viewer uses a sandboxed iframe `srcdoc` document so generated dashboard code never runs inside the main Preact bundle. For validated TSX/Preact revisions, it prefers the persisted Vite `dist` viewer artifact from revision runtime metadata and inlines the artifact's HTML, CSS, and JavaScript into the frame document. Older direct HTML or browser-ready JavaScript entry files still render through the previous entry-file path. The frame receives a frozen `codeUxDataBridge` / `CodeUXCustomDashboard` object and can request only declared source nodes by `id` through `postMessage`. Parent and frame handlers verify the expected window source. Generic dashboard/viewer records and frame configuration omit credential-binding IDs; only the dedicated metadata-management responses expose them. The parent page handles source requests with explicit same-origin API calls for project execution data, project stats, and overview telemetry; integration metadata is limited to non-secret source-node metadata; external API nodes are placeholders and return clear unavailable-source errors. Navigation is centralized through `dashboard/src/v2/lib/navigation-items.ts`, so both the kinetic dock and sidebar expose the Dashboards destination with stable labels, tour markers, and route prefetching. ## Docker and Logs -Docker argument construction lives in `src/services/custom-dashboard-docker-plan.ts`. Validation containers use the configured CLI workflow image, bind-mount only the generated workspace/runtime home plus an optional setup script, and do not mount provider credential directories. +Docker argument construction lives in `src/services/custom-dashboard-docker-plan.ts`. Validation containers use the configured CLI workflow image, bind-mount only the generated workspace/runtime home plus an optional setup script, and do not mount provider credential directories. The binding service never calls secret resolution, and credential values and binding IDs are not written to generated files, bridges, validation reports/logs, Docker arguments, iframe configuration, or browser messages. Logs are captured in the validation runtime directory and combined with bounded `docker logs` output through `getValidationLogs(sessionId, tail)`. `stopValidation` removes the detached container while preserving a passed revision report, and `removeValidation` removes the session row after container cleanup. diff --git a/docs/dashboard/custom-dashboards.md b/docs/dashboard/custom-dashboards.md index 739c6eae56..7c34644c4d 100644 --- a/docs/dashboard/custom-dashboards.md +++ b/docs/dashboard/custom-dashboards.md @@ -9,11 +9,12 @@ The source of truth is the Code UX database. Drafts stay mutable, revisions are 1. Ask the Project Manager for the dashboard you want. Include the purpose, target audience, data sources, layout preferences, review criteria, and whether the dashboard should be published after validation. 2. Review the draft in the dashboard workspace at `/custom-dashboards`. The draft includes editable manifest JSON, generated file bundle content, source-node graph JSON, styleguide JSON, and data catalog selections. 3. Ask for changes or edit the draft before creating a revision. Draft edits do not change previous revisions or the currently published dashboard. -4. Create a revision when the draft is ready. A revision snapshots the current manifest, file bundle, source graph, styleguide, and runtime metadata. -5. Run detached validation for the revision. Code UX materializes the bundle under the project `.code-ux/runtime/custom-dashboards/...` directory, builds it in Docker, starts a detached preview container, and health-checks the root URL. -6. Inspect validation status, logs, and the proxied preview link. Validation passes only after install, build, browser artifact capture, container start, and root health checks succeed. A passed validation does not publish by itself. -7. Publish the validated revision. The UI and repository gate publication to revisions with `validationStatus: "passed"` and a valid validation report. Publishing another passed revision is the rollback path. -8. Archive dashboards you no longer want active. Archiving clears the active publication and marks the dashboard archived while preserving revision and validation history. +4. If the manifest declares credential slots, review them through the credential-binding management surface. Bind each required slot to a compatible credential ID; no secret value is entered into the dashboard draft or generated code. +5. Create a revision when the draft is ready. A revision snapshots the current manifest, file bundle, source graph, styleguide, runtime metadata, and credential-ID bindings. +6. Run detached validation for the revision. Code UX reviews bindings before it materializes the bundle, builds it in Docker, starts a detached preview container, and health-checks the root URL. +7. Inspect validation status, logs, and the proxied preview link. Validation passes only after credential policy, install, build, browser artifact capture, container start, and root health checks succeed. A passed validation does not publish by itself. +8. Publish the validated revision. Publication rechecks credential metadata and requires `validationStatus: "passed"` with a valid validation report. Publishing another passed revision is the rollback path. +9. Archive dashboards you no longer want active. Archiving clears the active publication and marks the dashboard archived while preserving revision and validation history. If validation fails, use the report and logs to create a new revision. Do not publish around the failure; the repository rejects failed, queued, running, cancelled, missing, or mismatched validation sessions before publication state changes. When a dashboard is already published, validating later drafts keeps the active published dashboard open, and validation sessions for the active published revision do not replace its published validation snapshot. @@ -26,11 +27,12 @@ Recommended sequence: 1. Gather missing requirements for purpose, audience, source data, style, accessibility, and publication intent. 2. Call `data_catalog` for the project when reusing existing custom-dashboard source declarations. 3. Call `create` or `update` with a complete manifest, file bundle, source-node graph, styleguide, and runtime metadata. -4. Call `create_revision` to snapshot the draft. -5. Call `validate_revision`, then poll `validation_status` and read `validation_logs` when the session is not passed. -6. Repair failures by updating the draft and creating a new revision. -7. Call `publish_revision` only after validation passed. Include `validationSessionId` when publishing from the session just reviewed. -8. Use `archive` only after human approval; the action follows the standard destructive-action approval flow. +4. Call `list_credential_slots` when the manifest declares slots. Select only candidate credential IDs reported compatible, then call `bind_credential` with the current `expectedBindingRevision` and complete the human-approval flow. Use `unbind_credential` before changing a bound slot's policy. +5. Call `create_revision` to snapshot the draft and binding IDs. +6. Call `validate_revision`, then poll `validation_status` and read `validation_logs` when the session is not passed. +7. Repair failures by updating the draft or binding metadata and creating a new revision. +8. Call `publish_revision` only after validation passed. Include `validationSessionId` when publishing from the session just reviewed. +9. Use `archive` only after human approval; the action follows the standard destructive-action approval flow. ## Data-Source Node Graph @@ -76,6 +78,9 @@ Custom dashboard routes are registered with the dashboard server: | `POST` | `/api/custom-dashboards/:dashboardId/revisions` | Create an immutable revision from the draft or supplied overrides. | | `POST` | `/api/custom-dashboards/:dashboardId/revisions/:revisionId/validate` | Start a detached validation session. Body may include `projectId`; otherwise the server resolves it from the revision. | | `POST` | `/api/custom-dashboards/:dashboardId/revisions/:revisionId/publish` | Publish a validated revision, optionally with `validationSessionId`. | +| `GET` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings?revisionId=...` | Review draft or revision slots, current bindings, backend health, and bounded compatible credential metadata. | +| `PUT` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` | Bind or replace one slot using `slotId`, `credentialId`, and `expectedBindingRevision`. | +| `DELETE` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings/:slotId` | Unbind one slot using `expectedBindingRevision`. | | `GET` | `/api/custom-dashboard-validations/:sessionId` | Read validation session status and runtime metadata. | | `GET` | `/api/custom-dashboard-validations/:sessionId/logs?tail=200` | Read bounded validation and container logs. | | `POST` | `/api/custom-dashboard-validations/:sessionId/stop` | Stop the detached validation container. | @@ -83,7 +88,7 @@ Custom dashboard routes are registered with the dashboard server: | `ALL` | `/api/custom-dashboard-validations/:sessionId/proxy{*rest}` | Same-origin proxy to the detached validation runtime. | | `ALL` | `/api/custom-dashboards/validation-sessions/:sessionId/proxy{*rest}` | Backward-compatible validation proxy route. | -Publication is gated in `CustomDashboardRepository.publishRevision`. The requested revision must belong to the dashboard, must be marked `passed`, must have `validatedAt`, and must have `validationReport.valid === true`. If `validationSessionId` is supplied, that session must also belong to the same dashboard/revision/project and be passed with a valid report. Active publications remain the opening source of truth while later validation sessions run. +The binding routes are credential-management routes: authenticated remote callers require `credential_admin`, project access, and enabled remote credential management. Stale binding revisions return `409`; incompatible credential selection returns `403`. Publication first repeats metadata-only binding review, then applies the repository validation gate. Active publications remain the opening source of truth while later validation sessions run. ## MCP Surface @@ -95,8 +100,9 @@ The dedicated MCP tool is `manage_custom_dashboards` and is available to the pro - `publish_revision` - `archive` - `data_catalog` +- `list_credential_slots`, `bind_credential`, `unbind_credential` -Important payload fields include `projectId`, `dashboardId`, `revisionId`, `sessionId`, `validationSessionId`, `title`, `description`, `manifest`, `fileBundle`, `sourceNodeGraph`, `styleguide`, `runtimeMetadata`, `tail`, and `approval`. +Credential actions use `projectId`, `dashboardId`, `slotId`, `credentialId`, and `expectedBindingRevision`; an optional `revisionId` reviews an immutable snapshot. Bind and unbind require the normal stateful human-approval handshake. They reject secret, header, environment, and other undeclared fields. Other important fields include `sessionId`, `validationSessionId`, `title`, `description`, `manifest`, `fileBundle`, `sourceNodeGraph`, `styleguide`, `runtimeMetadata`, `tail`, and `approval`. The dashboard chat JSON-action bridge also understands the legacy `custom_dashboards` management domain, but agents should prefer the dedicated MCP tool when it is available. @@ -106,6 +112,7 @@ Validation sessions move through `queued`, `building`, `running`, `passed`, `fai During validation, Code UX: +- performs metadata-only compatibility review for every bound slot and every required slot - creates a validation session row and runtime directory under the selected project - writes the generated bundle plus a known Vite/Preact harness - injects a read-only `codeUxDataBridge` / `CodeUXCustomDashboard` object @@ -115,6 +122,8 @@ During validation, Code UX: - health-checks the root URL before marking the session passed - records workspace path, log path, container id/name, host port, validation proxy path, commands, and log excerpts in runtime metadata +Required missing bindings and bound credentials that are missing, revoked, inaccessible, unconfigured, wrong-kind, missing capabilities, or blocked by unavailable/insecure key custody fail with slot-specific issues before workspace creation. Optional unbound slots remain valid. No custom-dashboard path resolves secret plaintext: credential values and binding IDs stay out of generated source, file bundles, bridge files, Docker arguments and mounts, validation reports and logs, viewer records, iframe configuration, and browser messages. Dedicated credential-binding management responses may return credential IDs and non-secret metadata so operators and agents can select them. + Stopping a validation session removes the detached container. It does not invalidate a passed revision report. Removing a validation session deletes the session row after cleanup; the revision's validation metadata remains the publication gate. ## Published Viewer and Rollback diff --git a/docs/mcp/tools-and-contracts.md b/docs/mcp/tools-and-contracts.md index 5c04365310..eb13e0b0dd 100644 --- a/docs/mcp/tools-and-contracts.md +++ b/docs/mcp/tools-and-contracts.md @@ -251,20 +251,25 @@ The restricted tool intentionally does not expose due-entry execution, arbitrary - `publish_revision` publishes only a revision that is already marked passed with a valid report or a revision accompanied by a passed `validationSessionId`. - `archive` clears any active publication and marks the dashboard archived. It follows the normal destructive-action approval fingerprint flow. - `data_catalog` returns project dashboard summaries and declared source nodes for agents building or inspecting generated dashboards. +- `list_credential_slots` returns a bounded metadata-only review of declared slots, current bindings, backend health, and compatible credential candidates for the owning project. An optional `revisionId` reviews an immutable revision. +- `bind_credential` binds or replaces one declared slot by credential ID with `expectedBindingRevision`; `unbind_credential` removes one slot binding with the same optimistic guard. Both mutations require the stateful human-confirmation handshake. Payload fields: -- `projectId` is required for `list`, `create`, `validate_revision`, and `data_catalog`. -- `dashboardId` is required for `get`, `update`, `create_revision`, `validate_revision`, `publish_revision`, and `archive`. +- `projectId` is required for `list`, `create`, `validate_revision`, `data_catalog`, and every credential-binding action. +- `dashboardId` is required for `get`, `update`, `create_revision`, `validate_revision`, `publish_revision`, `archive`, and every credential-binding action. - `revisionId` is required for `validate_revision` and `publish_revision`. - `sessionId` is required for `validation_status` and `validation_logs`. - `validationSessionId` is optional for `publish_revision` and, when supplied, must identify a passed session for the same dashboard, revision, and project. - `manifest`, `fileBundle`, `sourceNodeGraph`, `styleguide`, and `runtimeMetadata` are accepted by `create`, `update`, and `create_revision` according to each action's required fields. - `tail` limits validation log output. +- `slotId`, `credentialId`, and `expectedBindingRevision` identify a metadata-only bind/replace operation; unbind omits `credentialId`. Secret-bearing and undeclared fields are rejected. Validation sessions move through `queued`, `building`, `running`, `passed`, `failed`, or `cancelled`. `validate_revision` starts the detached Docker validation runtime; it does not publish the revision. A passed session means install, build, detached preview startup, and root health checks completed successfully. -`publish_revision` is gated by repository state. The revision must belong to the dashboard, have `validationStatus: "passed"`, have `validatedAt`, and have `validationReport.valid === true`. Failed, queued, running, cancelled, missing, or cross-revision validation sessions are rejected before publication state changes, so the prior published revision remains active. +`validate_revision` and `publish_revision` perform a fresh metadata-only credential compatibility review. Required unbound slots and missing, revoked, inaccessible, unconfigured, wrong-kind, insufficient-capability, or unavailable-backend bindings fail closed with slot-specific validation issues. Optional unbound slots remain valid. Only after that review does `publish_revision` apply the repository validation-state gate, so the prior published revision remains active on any denial. + +Generic custom-dashboard MCP responses omit binding IDs. Only `list_credential_slots`, `bind_credential`, and `unbind_credential` may return binding IDs and non-secret credential metadata. These actions never accept or return plaintext and never call credential secret resolution. The generated dashboard data-source graph is user-declared JSON with `nodes`, `edges`, and optional `metadata`. Runtime viewer source types currently map to Code UX project execution data, project stats, overview telemetry, non-secret integration metadata, and unavailable `external_api` placeholders. Do not claim arbitrary external API connectors are available through this surface until a dedicated sanitized proxy contract exists. diff --git a/docs/operations/credential-security.md b/docs/operations/credential-security.md index 154a0383e1..59dcdeb3e9 100644 --- a/docs/operations/credential-security.md +++ b/docs/operations/credential-security.md @@ -45,6 +45,8 @@ Credential creation commits metadata and its first envelope in one SQLite transa Lifecycle successes and denials emit correlation-aware automation audit records containing credential IDs and policy metadata only. Validation updates report `valid`, `invalid`, or `unavailable` without including tested values or low-level cryptographic errors. +Custom dashboards use a stricter metadata-only consumer boundary. Dedicated slot declarations define allowed kinds and required capabilities, while separate draft and immutable-revision binding columns store credential IDs. Binding review delegates to the broker's compatibility assessment and never resolves plaintext. Required or invalid bindings stop validation before workspace creation and are rechecked before publication. Credential values and binding IDs are excluded from generated dashboard artifacts, Docker configuration, validation output, generic REST/MCP responses, and iframe messages; only the dedicated binding-management response may expose IDs with non-secret metadata. + Existing global credentials created before management ownership was stored are migrated with their first valid allowlisted project as the management owner. Operators should verify that owner before expanding a legacy global credential's allowlist. ## API surface diff --git a/src/app/dependency-factory/core-factory.ts b/src/app/dependency-factory/core-factory.ts index f69c4d2abd..b643f747d6 100644 --- a/src/app/dependency-factory/core-factory.ts +++ b/src/app/dependency-factory/core-factory.ts @@ -66,6 +66,7 @@ import { SprintFileBrowserRepository } from "../../repositories/sprint-file-brow import { DockerService } from "../../services/docker-service.js"; import { CustomDashboardRepository } from "../../repositories/custom-dashboard-repository.js"; import { CustomDashboardValidationService } from "../../services/custom-dashboard-validation-service.js"; +import { CustomDashboardCredentialBindingService } from "../../services/custom-dashboard-credential-binding-service.js"; import { AutomationCredentialRepository } from "../../repositories/automation-credential-repository.js"; import { AutomationApprovalRepository } from "../../repositories/automation-approval-repository.js"; import { AutomationOutboxRepository } from "../../repositories/automation-outbox-repository.js"; @@ -135,6 +136,7 @@ export interface CoreDependencies { sprintFileBrowserService: SprintFileBrowserService; sprintFileBrowserRepository: SprintFileBrowserRepository; customDashboardRepository: CustomDashboardRepository; + customDashboardCredentialBindingService: CustomDashboardCredentialBindingService; customDashboardValidationService: CustomDashboardValidationService; automationCredentialRepository: AutomationCredentialRepository; automationApprovalRepository: AutomationApprovalRepository; @@ -288,8 +290,15 @@ export function createCoreDependencies( logger: logger.child({ component: "sprint-file-browser-service" }), }); const customDashboardRepository = new CustomDashboardRepository(appDbStorage); + const customDashboardCredentialBindingService = new CustomDashboardCredentialBindingService({ + customDashboardRepository, + projectManagementRepository, + credentialBroker, + auditService: automationAuditService, + }); const customDashboardValidationService = new CustomDashboardValidationService({ customDashboardRepository, + customDashboardCredentialBindingService, projectManagementRepository, settingsRepository, logger: logger.child({ component: "custom-dashboard-validation-service" }), @@ -434,6 +443,7 @@ export function createCoreDependencies( sprintFileBrowserService, sprintFileBrowserRepository, customDashboardRepository, + customDashboardCredentialBindingService, customDashboardValidationService, automationCredentialRepository, automationApprovalRepository, diff --git a/src/app/dependency-factory/dashboard-factory.ts b/src/app/dependency-factory/dashboard-factory.ts index 26bd93a1fb..d98742bcde 100644 --- a/src/app/dependency-factory/dashboard-factory.ts +++ b/src/app/dependency-factory/dashboard-factory.ts @@ -148,6 +148,7 @@ export function createDashboardDependencies( const managementToolHandler = new ManagementToolHandler({ sprintPreviewService: coreDeps.sprintPreviewService, customDashboardRepository: coreDeps.customDashboardRepository, + customDashboardCredentialBindingService: coreDeps.customDashboardCredentialBindingService, customDashboardValidationService: coreDeps.customDashboardValidationService, executionRepository: coreDeps.executionRepository, getDashboardSettings: () => resolveDashboardSettings(), diff --git a/src/app/dependency-factory/mcp-factory.ts b/src/app/dependency-factory/mcp-factory.ts index 4b16c427ec..cccb557ba0 100644 --- a/src/app/dependency-factory/mcp-factory.ts +++ b/src/app/dependency-factory/mcp-factory.ts @@ -70,6 +70,7 @@ export function createMcpDependencies( const managementToolHandler = new ManagementToolHandler({ sprintPreviewService: coreDeps.sprintPreviewService, customDashboardRepository: coreDeps.customDashboardRepository, + customDashboardCredentialBindingService: coreDeps.customDashboardCredentialBindingService, customDashboardValidationService: coreDeps.customDashboardValidationService, executionRepository: coreDeps.executionRepository, getDashboardSettings: () => getDashboardSettings(), diff --git a/src/app/lifecycle/dashboard-lifecycle-service.ts b/src/app/lifecycle/dashboard-lifecycle-service.ts index 29e336ad1d..3166bbacd5 100644 --- a/src/app/lifecycle/dashboard-lifecycle-service.ts +++ b/src/app/lifecycle/dashboard-lifecycle-service.ts @@ -75,6 +75,7 @@ import type { EmbeddingService } from "../../services/embedding-service.js"; import type { MemoryRepository } from "../../repositories/memory-repository.js"; import type { CustomDashboardRepository } from "../../repositories/custom-dashboard-repository.js"; import type { CustomDashboardValidationService } from "../../services/custom-dashboard-validation-service.js"; +import type { CustomDashboardCredentialBindingService } from "../../services/custom-dashboard-credential-binding-service.js"; import type { SkillService } from "../../services/skill-service.js"; import type { GuardrailService } from "../../services/guardrail-service.js"; import type { ProjectSettings } from "../../contracts/settings-scope-types.js"; @@ -153,6 +154,7 @@ export interface BootDashboardDeps { headlessReadinessService: HeadlessOperationalReadinessService; automationSloService: AutomationSloService; customDashboardRepository?: CustomDashboardRepository; + customDashboardCredentialBindingService?: CustomDashboardCredentialBindingService; customDashboardValidationService?: CustomDashboardValidationService; skillService: SkillService; dashboardRealtimeService: DashboardRealtimeService; @@ -521,6 +523,7 @@ export async function bootDashboard(deps: BootDashboardDeps): Promise DashboardSettings; @@ -135,6 +137,7 @@ export class ManagementToolHandler { this.previewActions = new PreviewActions(deps.sprintPreviewService); this.customDashboardActions = new CustomDashboardActions( deps.customDashboardRepository, + deps.customDashboardCredentialBindingService, deps.customDashboardValidationService, ); this.chatProviderActions = new ChatProviderActions(deps.chatProviderRepository); @@ -243,6 +246,8 @@ export class ManagementToolHandler { || args.action.startsWith("replace_") || args.action === "remove_session" || args.action === "archive" + || args.action === "bind_credential" + || args.action === "unbind_credential" || args.action === "publish" || args.action === "rollback" || args.action === "deprecate_claim"; diff --git a/src/mcp/management/custom-dashboard-actions.ts b/src/mcp/management/custom-dashboard-actions.ts index ceb23f12fc..bdcfac1f3c 100644 --- a/src/mcp/management/custom-dashboard-actions.ts +++ b/src/mcp/management/custom-dashboard-actions.ts @@ -10,6 +10,11 @@ import type { } from "../../contracts/internal-management-types.js"; import type { CustomDashboardRepository } from "../../repositories/custom-dashboard-repository.js"; import type { CustomDashboardValidationService } from "../../services/custom-dashboard-validation-service.js"; +import { + type CustomDashboardCredentialBindingService, + withoutCustomDashboardCredentialBindings, + withoutCustomDashboardRevisionCredentialBindings, +} from "../../services/custom-dashboard-credential-binding-service.js"; import { managementValidationError, parseOptionalObject, @@ -21,6 +26,7 @@ import { export class CustomDashboardActions { constructor( private readonly customDashboardRepository: CustomDashboardRepository, + private readonly customDashboardCredentialBindingService: CustomDashboardCredentialBindingService, private readonly customDashboardValidationService: CustomDashboardValidationService, ) {} @@ -49,6 +55,12 @@ export class CustomDashboardActions { return this.archiveDashboard(args, payload); case "data_catalog": return this.dataCatalog(payload); + case "list_credential_slots": + return await this.listCredentialSlots(payload); + case "bind_credential": + return await this.bindCredential(args, payload); + case "unbind_credential": + return await this.unbindCredential(args, payload); default: throw managementValidationError(`Unknown custom dashboard action: ${args.action}`, "action"); } @@ -56,7 +68,12 @@ export class CustomDashboardActions { private listDashboards(payload: Record): ManagementResponseEnvelope { const projectId = parseRequiredString(payload, "projectId"); - return { result: { dashboards: this.customDashboardRepository.listDashboardsByProject(projectId) } }; + return { + result: { + dashboards: this.customDashboardRepository.listDashboardsByProject(projectId) + .map(withoutCustomDashboardCredentialBindings), + }, + }; } private getDashboard(payload: Record): ManagementResponseEnvelope { @@ -67,8 +84,9 @@ export class CustomDashboardActions { } return { result: { - dashboard, - revisions: this.customDashboardRepository.listRevisions(dashboard.id), + dashboard: withoutCustomDashboardCredentialBindings(dashboard), + revisions: this.customDashboardRepository.listRevisions(dashboard.id) + .map(withoutCustomDashboardRevisionCredentialBindings), }, }; } @@ -85,7 +103,7 @@ export class CustomDashboardActions { styleguide: draft.styleguide, runtimeMetadata: draft.runtimeMetadata, }); - return { result: { dashboard } }; + return { result: { dashboard: withoutCustomDashboardCredentialBindings(dashboard) } }; } private updateDashboard(payload: Record): ManagementResponseEnvelope { @@ -94,7 +112,7 @@ export class CustomDashboardActions { dashboardId, parseDashboardDraftPayload(payload, false), ); - return { result: { dashboard } }; + return { result: { dashboard: withoutCustomDashboardCredentialBindings(dashboard) } }; } private createRevision(payload: Record): ManagementResponseEnvelope { @@ -103,7 +121,7 @@ export class CustomDashboardActions { dashboardId, parseRevisionPayload(payload), ); - return { result: { revision } }; + return { result: { revision: withoutCustomDashboardRevisionCredentialBindings(revision) } }; } private async validateRevision(payload: Record): Promise { @@ -129,13 +147,24 @@ export class CustomDashboardActions { return { result: await this.customDashboardValidationService.getValidationLogs(sessionId, tail) }; } - private publishRevision(payload: Record): ManagementResponseEnvelope { + private async publishRevision(payload: Record): Promise { + const dashboardId = parseRequiredString(payload, "dashboardId"); + const revisionId = parseRequiredString(payload, "revisionId"); + const dashboardRecord = this.customDashboardRepository.getDashboardById(dashboardId); + if (!dashboardRecord) { + throw managementValidationError(`Custom dashboard not found: ${dashboardId}`, "dashboardId"); + } + await this.customDashboardCredentialBindingService.requireValidRevision( + dashboardRecord.projectId, + dashboardId, + revisionId, + ); const dashboard = this.customDashboardRepository.publishRevision( - parseRequiredString(payload, "dashboardId"), - parseRequiredString(payload, "revisionId"), + dashboardId, + revisionId, parseOptionalString(payload, "validationSessionId"), ); - return { result: { dashboard } }; + return { result: { dashboard: withoutCustomDashboardCredentialBindings(dashboard) } }; } private archiveDashboard(args: ManageCodeUxArgs, payload: Record): ManagementResponseEnvelope { @@ -146,7 +175,13 @@ export class CustomDashboardActions { approvalMessage: `Archiving custom dashboard ${dashboardId} removes its active publication. Call again with approval.confirmed true after human approval.`, }; } - return { result: { dashboard: this.customDashboardRepository.archiveDashboard(dashboardId) } }; + return { + result: { + dashboard: withoutCustomDashboardCredentialBindings( + this.customDashboardRepository.archiveDashboard(dashboardId), + ), + }, + }; } private dataCatalog(payload: Record): ManagementResponseEnvelope { @@ -173,6 +208,74 @@ export class CustomDashboardActions { }, }; } + + private async listCredentialSlots(payload: Record): Promise { + const projectId = parseRequiredString(payload, "projectId"); + const dashboardId = parseRequiredString(payload, "dashboardId"); + const revisionId = parseOptionalString(payload, "revisionId"); + return { + result: { + bindings: await this.customDashboardCredentialBindingService.listCredentialSlots( + projectId, + dashboardId, + revisionId, + ), + }, + }; + } + + private async bindCredential( + args: ManageCodeUxArgs, + payload: Record, + ): Promise { + const dashboardId = parseRequiredString(payload, "dashboardId"); + if (args.approval?.confirmed !== true) { + return { + approvalRequired: true, + approvalMessage: `Binding a credential to custom dashboard ${dashboardId} requires human approval. Review the metadata-only slot selection and call again with approval.confirmed true.`, + }; + } + return { + result: { + bindings: await this.customDashboardCredentialBindingService.bindCredential( + parseRequiredString(payload, "projectId"), + dashboardId, + bindingMutationInput(payload, false), + ), + }, + }; + } + + private async unbindCredential( + args: ManageCodeUxArgs, + payload: Record, + ): Promise { + const dashboardId = parseRequiredString(payload, "dashboardId"); + if (args.approval?.confirmed !== true) { + return { + approvalRequired: true, + approvalMessage: `Unbinding a credential from custom dashboard ${dashboardId} requires human approval. Review the affected slot and call again with approval.confirmed true.`, + }; + } + return { + result: { + bindings: await this.customDashboardCredentialBindingService.unbindCredential( + parseRequiredString(payload, "projectId"), + dashboardId, + bindingMutationInput(payload, true), + ), + }, + }; + } +} + +function bindingMutationInput(payload: Record, unbind: boolean): Record { + const envelopeKeys = new Set(["action", "approval", "projectId", "dashboardId"]); + const input = Object.fromEntries(Object.entries(payload).filter(([key]) => !envelopeKeys.has(key))); + if (unbind && "credentialId" in input) { + throw managementValidationError("unbind_credential does not accept credentialId or secret-bearing fields", "credentialId"); + } + return input; } function parseDashboardDraftPayload( diff --git a/src/server/code-ux-server.ts b/src/server/code-ux-server.ts index 22edd18a57..a108ba9301 100644 --- a/src/server/code-ux-server.ts +++ b/src/server/code-ux-server.ts @@ -183,6 +183,7 @@ export class CodeUxServer { private sprintPreviewService: SprintPreviewService; private sprintFileBrowserService: SprintFileBrowserService; private customDashboardRepository: import("../repositories/custom-dashboard-repository.js").CustomDashboardRepository; + private customDashboardCredentialBindingService: import("../services/custom-dashboard-credential-binding-service.js").CustomDashboardCredentialBindingService; private customDashboardValidationService: import("../services/custom-dashboard-validation-service.js").CustomDashboardValidationService; private agentPresetSyncService: AgentPresetSyncService; private executionRepository: ExecutionRepository; @@ -279,6 +280,7 @@ export class CodeUxServer { this.sprintPreviewService = deps.sprintPreviewService; this.sprintFileBrowserService = deps.sprintFileBrowserService; this.customDashboardRepository = deps.customDashboardRepository; + this.customDashboardCredentialBindingService = deps.customDashboardCredentialBindingService; this.customDashboardValidationService = deps.customDashboardValidationService; this.sprintMarkdownService = deps.sprintMarkdownService; this.sprintIssueService = deps.sprintIssueService; @@ -1459,6 +1461,7 @@ export class CodeUxServer { headlessReadinessService: this.headlessReadinessService, automationSloService: this.automationSloService, customDashboardRepository: this.customDashboardRepository, + customDashboardCredentialBindingService: this.customDashboardCredentialBindingService, customDashboardValidationService: this.customDashboardValidationService, skillService: this.skillService, chatThreadRuntimeService: this.chatThreadRuntimeService, diff --git a/src/server/custom-dashboard-routes.ts b/src/server/custom-dashboard-routes.ts index 7688c64d04..3c44650a34 100644 --- a/src/server/custom-dashboard-routes.ts +++ b/src/server/custom-dashboard-routes.ts @@ -8,19 +8,26 @@ import { HttpRouteError } from "./http-errors.js"; import type { DashboardDependencies } from "./dashboard-server.js"; import { asyncRoute } from "./route-utils.js"; import { requireTrimmedString } from "./request-parsers.js"; +import { + withoutCustomDashboardCredentialBindings, + withoutCustomDashboardRevisionCredentialBindings, +} from "../services/custom-dashboard-credential-binding-service.js"; export function registerCustomDashboardRoutes(app: Express, deps: DashboardDependencies): void { app.get("/api/projects/:projectId/custom-dashboards", asyncRoute(async (req, res) => { const repository = requireCustomDashboardRepository(deps); const projectId = requireTrimmedString(req.params.projectId, "projectId"); - res.json({ dashboards: repository.listDashboardsByProject(projectId) }); + res.json({ + dashboards: repository.listDashboardsByProject(projectId) + .map(withoutCustomDashboardCredentialBindings), + }); })); app.post("/api/projects/:projectId/custom-dashboards", asyncRoute(async (req, res) => { const repository = requireCustomDashboardRepository(deps); const projectId = requireTrimmedString(req.params.projectId, "projectId"); const dashboard = repository.createDraft(projectId, requireObjectBody(req.body)); - res.status(201).json(dashboard); + res.status(201).json(withoutCustomDashboardCredentialBindings(dashboard)); })); app.get("/api/projects/:projectId/custom-dashboards/data-catalog", asyncRoute(async (req, res) => { @@ -53,7 +60,11 @@ export function registerCustomDashboardRoutes(app: Express, deps: DashboardDepen if (!dashboard) { throw new HttpRouteError(404, `Custom dashboard not found: ${dashboardId}`); } - res.json({ dashboard, revisions: repository.listRevisions(dashboard.id) }); + res.json({ + dashboard: withoutCustomDashboardCredentialBindings(dashboard), + revisions: repository.listRevisions(dashboard.id) + .map(withoutCustomDashboardRevisionCredentialBindings), + }); })); app.patch("/api/custom-dashboards/:dashboardId", asyncRoute(async (req, res) => { @@ -62,12 +73,14 @@ export function registerCustomDashboardRoutes(app: Express, deps: DashboardDepen requireTrimmedString(req.params.dashboardId, "dashboardId"), requireObjectBody(req.body), ); - res.json(dashboard); + res.json(withoutCustomDashboardCredentialBindings(dashboard)); })); app.delete("/api/custom-dashboards/:dashboardId", asyncRoute(async (req, res) => { const repository = requireCustomDashboardRepository(deps); - res.json(repository.archiveDashboard(requireTrimmedString(req.params.dashboardId, "dashboardId"))); + res.json(withoutCustomDashboardCredentialBindings( + repository.archiveDashboard(requireTrimmedString(req.params.dashboardId, "dashboardId")), + )); })); app.post("/api/custom-dashboards/:dashboardId/revisions", asyncRoute(async (req, res) => { @@ -77,7 +90,7 @@ export function registerCustomDashboardRoutes(app: Express, deps: DashboardDepen requireTrimmedString(req.params.dashboardId, "dashboardId"), body, ); - res.status(201).json(revision); + res.status(201).json(withoutCustomDashboardRevisionCredentialBindings(revision)); })); app.post("/api/custom-dashboards/:dashboardId/revisions/:revisionId/validate", asyncRoute(async (req, res) => { @@ -97,12 +110,50 @@ export function registerCustomDashboardRoutes(app: Express, deps: DashboardDepen const validationSessionId = typeof body.validationSessionId === "string" ? body.validationSessionId : undefined; + const dashboardId = requireTrimmedString(req.params.dashboardId, "dashboardId"); + const existing = repository.getDashboardById(dashboardId); + if (!existing) { + throw new HttpRouteError(404, `Custom dashboard not found: ${dashboardId}`); + } + await requireCustomDashboardCredentialBindingService(deps).requireValidRevision( + existing.projectId, + dashboardId, + revisionId, + ); const dashboard = repository.publishRevision( - requireTrimmedString(req.params.dashboardId, "dashboardId"), + dashboardId, revisionId, validationSessionId, ); - res.json(dashboard); + res.json(withoutCustomDashboardCredentialBindings(dashboard)); + })); + + app.get("/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings", asyncRoute(async (req, res) => { + const revisionId = typeof req.query.revisionId === "string" && req.query.revisionId.trim() + ? req.query.revisionId + : undefined; + res.json(await requireCustomDashboardCredentialBindingService(deps).listCredentialSlots( + requireTrimmedString(req.params.projectId, "projectId"), + requireTrimmedString(req.params.dashboardId, "dashboardId"), + revisionId, + )); + })); + + app.put("/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings", asyncRoute(async (req, res) => { + res.json(await requireCustomDashboardCredentialBindingService(deps).bindCredential( + requireTrimmedString(req.params.projectId, "projectId"), + requireTrimmedString(req.params.dashboardId, "dashboardId"), + req.body, + )); + })); + + app.delete("/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings/:slotId", asyncRoute(async (req, res) => { + const body = requireObjectBody>(req.body); + res.json(await requireCustomDashboardCredentialBindingService(deps).unbindCredential( + requireTrimmedString(req.params.projectId, "projectId"), + requireTrimmedString(req.params.dashboardId, "dashboardId"), + { ...body, slotId: requireTrimmedString(req.params.slotId, "slotId") }, + )); })); app.get("/api/custom-dashboard-validations/:sessionId", asyncRoute(async (req, res) => { @@ -164,6 +215,15 @@ function requireCustomDashboardValidationService( return deps.customDashboardValidationService; } +function requireCustomDashboardCredentialBindingService( + deps: DashboardDependencies, +): NonNullable { + if (!deps.customDashboardCredentialBindingService) { + throw new Error("Custom dashboard credential binding service is unavailable."); + } + return deps.customDashboardCredentialBindingService; +} + function requireProjectIdForRevisionValidation(deps: DashboardDependencies, req: Request): string { const projectId = typeof req.body?.projectId === "string" ? req.body.projectId diff --git a/src/server/dashboard-server.ts b/src/server/dashboard-server.ts index e1e9af0703..7c128331c8 100644 --- a/src/server/dashboard-server.ts +++ b/src/server/dashboard-server.ts @@ -143,6 +143,7 @@ import type { SpeechModelManager } from "../services/speech-model-manager.js"; import type { NodeFlowService } from "../services/node-flow-service.js"; import type { CustomDashboardRepository } from "../repositories/custom-dashboard-repository.js"; import type { CustomDashboardValidationService } from "../services/custom-dashboard-validation-service.js"; +import type { CustomDashboardCredentialBindingService } from "../services/custom-dashboard-credential-binding-service.js"; import type { SkillService } from "../services/skill-service.js"; import type { CredentialBroker } from "../services/credentials/credential-broker.js"; import type { ApprovalService } from "../services/node-flows/approval-service.js"; @@ -199,6 +200,7 @@ export interface DashboardServerOptions { approvalService?: ApprovalService; automationWebhookTriggerRepository?: AutomationWebhookTriggerRepository; customDashboardRepository?: CustomDashboardRepository; + customDashboardCredentialBindingService?: CustomDashboardCredentialBindingService; customDashboardValidationService?: CustomDashboardValidationService; skillService?: SkillService; credentialBroker?: CredentialBroker; diff --git a/src/server/http-errors.ts b/src/server/http-errors.ts index c0c967592f..19ddbcc2b5 100644 --- a/src/server/http-errors.ts +++ b/src/server/http-errors.ts @@ -36,6 +36,11 @@ export function toHttpRouteError(error: unknown): HttpRouteError { return new HttpRouteError(409, msg); } + if (error && typeof error === "object" && "name" in error && error.name === "CustomDashboardCredentialBindingConflictError") { + const msg = "message" in error && typeof error.message === "string" ? error.message : "Custom dashboard credential bindings changed concurrently"; + return new HttpRouteError(409, msg); + } + if (error && typeof error === "object" && "name" in error && error.name === "CredentialKeyCustodyUnavailableError") { const msg = "message" in error && typeof error.message === "string" ? error.message : "Credential key custody is unavailable"; return new HttpRouteError(503, msg); diff --git a/src/services/custom-dashboard-credential-binding-service.ts b/src/services/custom-dashboard-credential-binding-service.ts new file mode 100644 index 0000000000..d58509cb88 --- /dev/null +++ b/src/services/custom-dashboard-credential-binding-service.ts @@ -0,0 +1,496 @@ +import type { + AutomationCredentialCompatibilityAssessment, + AutomationCredentialCompatibilityIssue, + AutomationCredentialMetadata, + CredentialBackendHealth, +} from "../contracts/automation-credential-types.js"; +import type { + CustomDashboardCredentialBinding, + CustomDashboardCredentialSlotDeclaration, + CustomDashboardRecord, + CustomDashboardRevisionRecord, + CustomDashboardValidationIssue, + CustomDashboardValidationReport, +} from "../contracts/custom-dashboard-types.js"; +import type { ProjectManagementRepository } from "../repositories/project-management-repository.js"; +import { + CustomDashboardCredentialBindingConflictError, + type CustomDashboardRepository, +} from "../repositories/custom-dashboard-repository.js"; +import { EntityNotFoundError, ValidationError } from "../repositories/repository-utils.js"; +import type { AutomationAuditExportService } from "./automation-audit-export-service.js"; +import { + CredentialAccessDeniedError, + type CredentialBroker, +} from "./credentials/credential-broker.js"; + +const MAX_IDENTIFIER_LENGTH = 256; +const MAX_CREDENTIAL_CANDIDATES = 100; +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; + +export interface CustomDashboardCredentialCandidate { + credentialId: string; + metadata: AutomationCredentialMetadata | null; + compatible: boolean; + issues: AutomationCredentialCompatibilityIssue[]; + missingCapabilities: string[]; +} + +export interface CustomDashboardCredentialSlotReview { + slot: CustomDashboardCredentialSlotDeclaration; + binding: CustomDashboardCredentialBinding | null; + metadata: AutomationCredentialMetadata | null; + compatible: boolean; + issues: CustomDashboardValidationIssue[]; + candidates?: CustomDashboardCredentialCandidate[]; +} + +export interface CustomDashboardCredentialBindingReview { + projectId: string; + dashboardId: string; + revisionId: string | null; + credentialBindingRevision: number | null; + backend: CredentialBackendHealth; + valid: boolean; + issues: CustomDashboardValidationIssue[]; + slots: CustomDashboardCredentialSlotReview[]; + credentialCandidateCount: number; + credentialCandidatesTruncated: boolean; +} + +export class CustomDashboardCredentialBindingValidationError extends ValidationError { + constructor(readonly review: CustomDashboardCredentialBindingReview) { + super(review.issues[0]?.message ?? "Custom dashboard credential bindings are invalid."); + this.name = "CustomDashboardCredentialBindingValidationError"; + } +} + +interface BindingServiceDependencies { + customDashboardRepository: CustomDashboardRepository; + projectManagementRepository: ProjectManagementRepository; + credentialBroker: CredentialBroker; + auditService?: AutomationAuditExportService; +} + +interface BindCredentialInput { + slotId: string; + credentialId: string; + expectedBindingRevision: number; +} + +interface UnbindCredentialInput { + slotId: string; + expectedBindingRevision: number; +} + +export class CustomDashboardCredentialBindingService { + constructor(private readonly deps: BindingServiceDependencies) {} + + async listCredentialSlots( + projectIdValue: string, + dashboardIdValue: string, + revisionIdValue?: string, + ): Promise { + const projectId = boundedString(projectIdValue, "projectId"); + const dashboardId = boundedString(dashboardIdValue, "dashboardId"); + const dashboard = this.requireDashboard(projectId, dashboardId); + const revisionId = revisionIdValue === undefined ? undefined : boundedString(revisionIdValue, "revisionId"); + const target = revisionId ? this.requireRevision(dashboard, revisionId) : dashboard; + return await this.reviewTarget(dashboard, target, true); + } + + async reviewRevision( + projectIdValue: string, + dashboardIdValue: string, + revisionIdValue: string, + ): Promise { + const projectId = boundedString(projectIdValue, "projectId"); + const dashboardId = boundedString(dashboardIdValue, "dashboardId"); + const revisionId = boundedString(revisionIdValue, "revisionId"); + const dashboard = this.requireDashboard(projectId, dashboardId); + return await this.reviewTarget(dashboard, this.requireRevision(dashboard, revisionId), false); + } + + async requireValidRevision( + projectId: string, + dashboardId: string, + revisionId: string, + ): Promise { + const review = await this.reviewRevision(projectId, dashboardId, revisionId); + if (!review.valid) { + throw new CustomDashboardCredentialBindingValidationError(review); + } + return review; + } + + async bindCredential( + projectIdValue: string, + dashboardIdValue: string, + inputValue: unknown, + ): Promise { + const projectId = boundedString(projectIdValue, "projectId"); + const dashboardId = boundedString(dashboardIdValue, "dashboardId"); + let slotId: string | null = null; + let credentialId: string | null = null; + let revision: number | null = null; + try { + const input = parseBindInput(inputValue); + slotId = input.slotId; + credentialId = input.credentialId; + const dashboard = this.requireDashboard(projectId, dashboardId); + revision = dashboard.credentialBindingRevision ?? 1; + this.requireExpectedRevision(dashboard, input.expectedBindingRevision); + const slot = this.requireSlot(dashboard, input.slotId); + const assessment = await this.deps.credentialBroker.assessCompatibility(input.credentialId, { + projectId, + allowedKinds: slot.allowedKinds, + requiredCapabilities: slot.requiredCapabilities, + }); + if (!assessment.compatible) { + const reason = issueMessage(slot, assessment.issues[0] ?? "not_configured", assessment.missingCapabilities); + throw new CredentialAccessDeniedError(reason); + } + const bindings = replaceBinding(dashboard.credentialBindings ?? [], { + slotId: input.slotId, + credentialId: input.credentialId, + }); + const updated = this.deps.customDashboardRepository.updateCredentialBindings(dashboard.id, { + expectedBindingRevision: input.expectedBindingRevision, + bindings, + }); + revision = updated.credentialBindingRevision ?? input.expectedBindingRevision + 1; + this.audit("custom_dashboard.credential.bind", projectId, dashboardId, revision, slotId, credentialId, "succeeded", null); + return await this.reviewTarget(updated, updated, true); + } catch (error) { + this.audit( + "custom_dashboard.credential.bind", + projectId, + dashboardId, + revision, + slotId, + credentialId, + bindingAuditOutcome(error), + safeDenialReason(error), + ); + throw error; + } + } + + async unbindCredential( + projectIdValue: string, + dashboardIdValue: string, + inputValue: unknown, + ): Promise { + const projectId = boundedString(projectIdValue, "projectId"); + const dashboardId = boundedString(dashboardIdValue, "dashboardId"); + let slotId: string | null = null; + let credentialId: string | null = null; + let revision: number | null = null; + try { + const input = parseUnbindInput(inputValue); + slotId = input.slotId; + const dashboard = this.requireDashboard(projectId, dashboardId); + revision = dashboard.credentialBindingRevision ?? 1; + this.requireExpectedRevision(dashboard, input.expectedBindingRevision); + this.requireSlot(dashboard, input.slotId); + credentialId = dashboard.credentialBindings?.find((binding) => binding.slotId === input.slotId)?.credentialId ?? null; + if (!credentialId) { + this.audit("custom_dashboard.credential.unbind", projectId, dashboardId, revision, slotId, null, "succeeded", null); + return await this.reviewTarget(dashboard, dashboard, true); + } + const updated = this.deps.customDashboardRepository.updateCredentialBindings(dashboard.id, { + expectedBindingRevision: input.expectedBindingRevision, + bindings: (dashboard.credentialBindings ?? []).filter((binding) => binding.slotId !== input.slotId), + }); + revision = updated.credentialBindingRevision ?? input.expectedBindingRevision + 1; + this.audit("custom_dashboard.credential.unbind", projectId, dashboardId, revision, slotId, credentialId, "succeeded", null); + return await this.reviewTarget(updated, updated, true); + } catch (error) { + this.audit( + "custom_dashboard.credential.unbind", + projectId, + dashboardId, + revision, + slotId, + credentialId, + bindingAuditOutcome(error), + safeDenialReason(error), + ); + throw error; + } + } + + toValidationReport(review: CustomDashboardCredentialBindingReview): CustomDashboardValidationReport { + return { + valid: review.valid, + summary: review.valid + ? "Custom dashboard credential bindings passed metadata-only policy review." + : "Custom dashboard credential bindings did not pass metadata-only policy review.", + issues: review.issues, + }; + } + + private async reviewTarget( + dashboard: CustomDashboardRecord, + target: CustomDashboardRecord | CustomDashboardRevisionRecord, + includeCandidates: boolean, + ): Promise { + const backend = await this.safeBackendHealth(); + const declarations = target.manifest.credentialSlots ?? []; + const bindings = target.credentialBindings ?? []; + const allMetadata = includeCandidates ? this.deps.credentialBroker.list(dashboard.projectId) : []; + const metadata = allMetadata.slice(0, MAX_CREDENTIAL_CANDIDATES); + const assessmentCache = new Map>(); + const assess = ( + credentialId: string, + slot: CustomDashboardCredentialSlotDeclaration, + ): Promise => { + const key = `${credentialId}\u0000${slot.allowedKinds.join("\u0000")}\u0001${slot.requiredCapabilities.join("\u0000")}`; + const cached = assessmentCache.get(key); + if (cached) return cached; + const pending = this.deps.credentialBroker.assessCompatibility(credentialId, { + projectId: dashboard.projectId, + allowedKinds: slot.allowedKinds, + requiredCapabilities: slot.requiredCapabilities, + }); + assessmentCache.set(key, pending); + return pending; + }; + + const slots = await Promise.all(declarations.map(async (slot): Promise => { + const binding = bindings.find((candidate) => candidate.slotId === slot.slotId) ?? null; + const assessment = binding ? await assess(binding.credentialId, slot) : null; + const issues = assessment + ? assessment.issues.map((issue) => validationIssue(slot, issue, assessment.missingCapabilities)) + : slot.required + ? [validationIssue(slot, "required_binding_missing", [])] + : []; + const candidates = includeCandidates + ? await Promise.all(metadata.map(async (credential): Promise => { + const candidateAssessment = await assess(credential.id, slot); + return { + credentialId: credential.id, + metadata: candidateAssessment.metadata, + compatible: candidateAssessment.compatible, + issues: candidateAssessment.issues, + missingCapabilities: candidateAssessment.missingCapabilities, + }; + })) + : undefined; + return { + slot, + binding, + metadata: assessment?.metadata ?? null, + compatible: issues.length === 0, + issues, + ...(candidates ? { candidates } : {}), + }; + })); + const issues = slots.flatMap((slot) => slot.issues); + return { + projectId: dashboard.projectId, + dashboardId: dashboard.id, + revisionId: "revisionNumber" in target ? target.id : null, + credentialBindingRevision: "revisionNumber" in target ? null : target.credentialBindingRevision ?? 1, + backend, + valid: issues.length === 0, + issues, + slots, + credentialCandidateCount: allMetadata.length, + credentialCandidatesTruncated: allMetadata.length > metadata.length, + }; + } + + private requireDashboard(projectId: string, dashboardId: string): CustomDashboardRecord { + if (!this.deps.projectManagementRepository.getProject(projectId)) { + throw new EntityNotFoundError(`Project not found: ${projectId}`); + } + const dashboard = this.deps.customDashboardRepository.getDashboardById(dashboardId); + if (!dashboard || dashboard.projectId !== projectId) { + throw new EntityNotFoundError(`Custom dashboard not found: ${dashboardId}`); + } + return dashboard; + } + + private requireRevision(dashboard: CustomDashboardRecord, revisionId: string): CustomDashboardRevisionRecord { + const revision = this.deps.customDashboardRepository.getRevisionById(revisionId); + if (!revision || revision.dashboardId !== dashboard.id || revision.projectId !== dashboard.projectId) { + throw new EntityNotFoundError(`Custom dashboard revision not found: ${revisionId}`); + } + return revision; + } + + private requireSlot( + dashboard: CustomDashboardRecord, + slotId: string, + ): CustomDashboardCredentialSlotDeclaration { + const slot = dashboard.manifest.credentialSlots?.find((candidate) => candidate.slotId === slotId); + if (!slot) { + throw new ValidationError(`Custom dashboard credential slot is not declared: ${slotId}`); + } + return slot; + } + + private requireExpectedRevision(dashboard: CustomDashboardRecord, expected: number): void { + const actual = dashboard.credentialBindingRevision ?? 1; + if (actual !== expected) { + throw new CustomDashboardCredentialBindingConflictError(dashboard.id, expected, actual); + } + } + + private async safeBackendHealth(): Promise { + try { + return await this.deps.credentialBroker.health(); + } catch { + return { + available: false, + secure: false, + provider: "unavailable", + keyId: null, + keyVersion: null, + reason: "Credential key provider health check failed.", + }; + } + } + + private audit( + action: string, + projectId: string, + dashboardId: string, + revision: number | null, + slotId: string | null, + credentialId: string | null, + outcome: "succeeded" | "denied" | "failed", + denialReason: string | null, + ): void { + try { + this.deps.auditService?.recordSystem({ + action, + resourceType: "custom_dashboard_credential_binding", + resourceId: dashboardId, + projectId, + outcome, + metadata: { dashboardId, revision, slotId, credentialId, denialReason }, + }); + } catch { + // Binding policy decisions must not depend on audit-store availability. + } + } +} + +export function withoutCustomDashboardCredentialBindings( + dashboard: CustomDashboardRecord, +): Omit { + const { credentialBindings: _credentialBindings, ...safe } = dashboard; + return safe; +} + +export function withoutCustomDashboardRevisionCredentialBindings( + revision: CustomDashboardRevisionRecord, +): Omit { + const { credentialBindings: _credentialBindings, ...safe } = revision; + return safe; +} + +function parseBindInput(value: unknown): BindCredentialInput { + const input = requireObject(value, "custom dashboard credential binding"); + rejectUnknownFields(input, ["slotId", "credentialId", "expectedBindingRevision"]); + return { + slotId: boundedString(input.slotId, "slotId"), + credentialId: boundedString(input.credentialId, "credentialId"), + expectedBindingRevision: positiveInteger(input.expectedBindingRevision, "expectedBindingRevision"), + }; +} + +function parseUnbindInput(value: unknown): UnbindCredentialInput { + const input = requireObject(value, "custom dashboard credential unbinding"); + rejectUnknownFields(input, ["slotId", "expectedBindingRevision"]); + return { + slotId: boundedString(input.slotId, "slotId"), + expectedBindingRevision: positiveInteger(input.expectedBindingRevision, "expectedBindingRevision"), + }; +} + +function requireObject(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new ValidationError(`${label} must be an object.`); + } + return value as Record; +} + +function rejectUnknownFields(input: Record, allowed: readonly string[]): void { + const unknown = Object.keys(input).filter((key) => !allowed.includes(key)); + if (unknown.length > 0) { + throw new ValidationError(`Custom dashboard credential binding contains unsupported fields: ${unknown.sort().join(", ")}.`); + } +} + +function boundedString(value: unknown, label: string): string { + if (typeof value !== "string") throw new ValidationError(`${label} must be a string.`); + const normalized = value.trim(); + if (!normalized) throw new ValidationError(`${label} is required.`); + if (normalized.length > MAX_IDENTIFIER_LENGTH) throw new ValidationError(`${label} must be at most ${MAX_IDENTIFIER_LENGTH} characters.`); + if (CONTROL_CHARACTERS.test(normalized)) throw new ValidationError(`${label} cannot contain control characters.`); + return normalized; +} + +function positiveInteger(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) { + throw new ValidationError(`${label} must be a positive safe integer.`); + } + return value; +} + +function replaceBinding( + bindings: CustomDashboardCredentialBinding[], + next: CustomDashboardCredentialBinding, +): CustomDashboardCredentialBinding[] { + return [...bindings.filter((binding) => binding.slotId !== next.slotId), next] + .sort((left, right) => left.slotId.localeCompare(right.slotId)); +} + +function validationIssue( + slot: CustomDashboardCredentialSlotDeclaration, + issue: AutomationCredentialCompatibilityIssue | "required_binding_missing", + missingCapabilities: string[], +): CustomDashboardValidationIssue { + return { + field: `credentialBindings.${slot.slotId}`, + code: issue, + message: issueMessage(slot, issue, missingCapabilities), + }; +} + +function issueMessage( + slot: CustomDashboardCredentialSlotDeclaration, + issue: AutomationCredentialCompatibilityIssue | "required_binding_missing", + missingCapabilities: string[], +): string { + const prefix = `Credential slot ${slot.slotId}`; + switch (issue) { + case "required_binding_missing": return `${prefix} requires a binding.`; + case "backend_unavailable": return `${prefix} is unavailable because secure credential-key custody is not ready.`; + case "backend_insecure": return `${prefix} is unavailable because credential-key custody is not secure.`; + case "not_configured": return `${prefix} is bound to a credential that is not configured.`; + case "not_active": return `${prefix} is bound to a credential that is not active.`; + case "project_access_denied": return `${prefix} is bound to a credential that is inaccessible to this project.`; + case "kind_not_allowed": return `${prefix} is bound to a credential kind that its declaration does not allow.`; + case "capability_missing": return missingCapabilities.length > 0 + ? `${prefix} is missing required capabilities: ${missingCapabilities.join(", ")}.` + : `${prefix} is missing one or more required capabilities.`; + } +} + +function safeDenialReason(error: unknown): string { + if (error instanceof CredentialAccessDeniedError) return error.message; + if (error instanceof CustomDashboardCredentialBindingConflictError) return "credential_binding_revision_conflict"; + if (error instanceof EntityNotFoundError) return "resource_not_found"; + if (error instanceof ValidationError) return "validation_failed"; + return "operation_failed"; +} + +function bindingAuditOutcome(error: unknown): "denied" | "failed" { + return error instanceof CredentialAccessDeniedError || error instanceof EntityNotFoundError + ? "denied" + : "failed"; +} diff --git a/src/services/custom-dashboard-validation-service.ts b/src/services/custom-dashboard-validation-service.ts index ddb34d4e95..42aca14420 100644 --- a/src/services/custom-dashboard-validation-service.ts +++ b/src/services/custom-dashboard-validation-service.ts @@ -40,6 +40,7 @@ import { DockerSessionLifecycle, sanitizeContainerNameComponent } from "./docker import { DockerBootstrapBuilder } from "../infrastructure/providers/cli/docker-bootstrap-builder.js"; import { assertSafePathSegment, isPathInside } from "../utils/path-validator.js"; import { managedRuntimeService, type ManagedRuntimeService } from "./managed-runtime-service.js"; +import type { CustomDashboardCredentialBindingService } from "./custom-dashboard-credential-binding-service.js"; const BUNDLED_CONTAINER_SETUP_SCRIPT = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -55,6 +56,7 @@ const VIEWER_ARTIFACT_MAX_TOTAL_BYTES = 8 * 1024 * 1024; export interface CustomDashboardValidationServiceDeps { customDashboardRepository: CustomDashboardRepository; + customDashboardCredentialBindingService: CustomDashboardCredentialBindingService; projectManagementRepository: ProjectManagementRepository; settingsRepository: SettingsRepository; logger?: Logger; @@ -118,6 +120,19 @@ export class CustomDashboardValidationService { throw new EntityNotFoundError(`Custom dashboard not found: ${dashboardId}`); } const revision = this.requireRevision(projectId, dashboardId, revisionId); + const bindingReview = await this.deps.customDashboardCredentialBindingService.reviewRevision( + projectId, + dashboardId, + revisionId, + ); + if (!bindingReview.valid) { + return this.deps.customDashboardRepository.createValidationSession(revision.id, { + status: "failed", + validationReport: this.deps.customDashboardCredentialBindingService.toValidationReport(bindingReview), + startedAt: new Date().toISOString(), + finishedAt: new Date().toISOString(), + }); + } const { runtimeRoot, workspacePath, runtimeHomePath, logPath } = await this.resolveValidationRuntimePaths( project.baseDir, dashboardId, diff --git a/src/services/custom-dashboard-validation-utils.ts b/src/services/custom-dashboard-validation-utils.ts index 2b4f36b041..660e7919ed 100644 --- a/src/services/custom-dashboard-validation-utils.ts +++ b/src/services/custom-dashboard-validation-utils.ts @@ -89,6 +89,7 @@ export async function materializeCustomDashboardWorkspace(args: { workspacePath: ValidatedCustomDashboardPath; bridgeConfig: CustomDashboardBridgeConfig; }): Promise { + assertBundleOmitsCredentialBindingIds(args.revision); // workspacePath is returned by resolveContainedCustomDashboardPath after // lexical and realpath containment checks against the project runtime root. // codeql[js/path-injection] @@ -131,13 +132,14 @@ export async function materializeCustomDashboardWorkspace(args: { const tsConfigPath = await resolveContainedCustomDashboardPath(args.workspacePath, path.join(args.workspacePath, "tsconfig.json")); const dataBridgePath = await resolveContainedCustomDashboardPath(args.workspacePath, path.join(harnessDir, "codeux-data-bridge.ts")); const harnessEntryPath = await resolveContainedCustomDashboardPath(args.workspacePath, path.join(harnessDir, "main.tsx")); + const safeBridgeConfig = sanitizeBridgeValue(args.bridgeConfig, credentialBindingIds(args.revision)); await Promise.all([ writeJsonFile(packageJsonPath, buildPackageJson()), writeTextFile(indexHtmlPath, buildIndexHtml()), writeTextFile(viteConfigPath, buildViteConfig()), writeTextFile(tsConfigPath, buildTsConfig()), - writeTextFile(dataBridgePath, buildDataBridgeModule(args.bridgeConfig)), + writeTextFile(dataBridgePath, buildDataBridgeModule(safeBridgeConfig)), writeTextFile(harnessEntryPath, buildHarnessEntry(entryImportPath)), ]); @@ -193,7 +195,7 @@ export async function readValidationLog(logPath: ValidatedCustomDashboardPath | } export function buildBridgeConfig(revision: CustomDashboardRevisionRecord): CustomDashboardBridgeConfig { - return { + const config: CustomDashboardBridgeConfig = { projectId: revision.projectId, dashboardId: revision.dashboardId, revisionId: revision.id, @@ -211,6 +213,48 @@ export function buildBridgeConfig(revision: CustomDashboardRevisionRecord): Cust config: extractJsonObject(node.config), })), }; + return sanitizeBridgeValue(config, credentialBindingIds(revision)); +} + +function assertBundleOmitsCredentialBindingIds(revision: CustomDashboardRevisionRecord): void { + const bindingIds = credentialBindingIds(revision); + if (bindingIds.size === 0) return; + if (revision.fileBundle.files.some((file) => [...bindingIds].some((credentialId) => file.content.includes(credentialId)))) { + throw new Error("Custom dashboard file bundles cannot contain credential binding identifiers."); + } +} + +function credentialBindingIds(revision: CustomDashboardRevisionRecord): Set { + return new Set((revision.credentialBindings ?? []).map((binding) => binding.credentialId)); +} + +function sanitizeBridgeValue(value: T, excludedIdentifiers: ReadonlySet): T { + return sanitizeBridgeUnknown(value, excludedIdentifiers) as T; +} + +function sanitizeBridgeUnknown(value: unknown, excludedIdentifiers: ReadonlySet): unknown { + if (typeof value === "string") { + return excludedIdentifiers.has(value) ? undefined : value; + } + if (Array.isArray(value)) { + return value + .map((entry) => sanitizeBridgeUnknown(entry, excludedIdentifiers)) + .filter((entry) => entry !== undefined); + } + if (!value || typeof value !== "object") return value; + + const safe: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const normalizedKey = key.toLowerCase(); + if (normalizedKey === "credentialbindings" + || normalizedKey === "credentialbindingrevision" + || normalizedKey === "credentialid") { + continue; + } + const sanitized = sanitizeBridgeUnknown(entry, excludedIdentifiers); + if (sanitized !== undefined) safe[key] = sanitized; + } + return safe; } function extractJsonObject(value: unknown): CustomDashboardJsonObject { diff --git a/src/services/headless-auth-service.ts b/src/services/headless-auth-service.ts index e175af585f..58bcae1ff0 100644 --- a/src/services/headless-auth-service.ts +++ b/src/services/headless-auth-service.ts @@ -109,7 +109,8 @@ function isCredentialManagementRequest(req: Request): boolean { const pathname = req.path.toLowerCase(); return pathname.startsWith("/api/credentials") || pathname.startsWith("/credentials") - || /\/credentials(?:\/|$)/.test(pathname); + || /\/credentials(?:\/|$)/.test(pathname) + || /\/custom-dashboards\/[^/]+\/credential-bindings(?:\/|$)/.test(pathname); } export function requiredRoleForDashboardRequest(req: Request): CodeUxRole { diff --git a/tests/backend/mcp/management-custom-dashboard-actions.test.ts b/tests/backend/mcp/management-custom-dashboard-actions.test.ts index 9e28ee2ce6..2d461201ad 100644 --- a/tests/backend/mcp/management-custom-dashboard-actions.test.ts +++ b/tests/backend/mcp/management-custom-dashboard-actions.test.ts @@ -13,6 +13,12 @@ import { CustomDashboardRepository } from "../../../src/repositories/custom-dash import { ProjectManagementRepository } from "../../../src/repositories/project-management-repository.js"; import { SettingsRepository } from "../../../src/repositories/settings-repository.js"; import { CustomDashboardValidationService } from "../../../src/services/custom-dashboard-validation-service.js"; +import { AutomationCredentialRepository } from "../../../src/repositories/automation-credential-repository.js"; +import { MountedKeyFileProvider } from "../../../src/infrastructure/security/mounted-key-file-provider.js"; +import { EncryptedSqliteSecretStore } from "../../../src/infrastructure/security/encrypted-sqlite-secret-store.js"; +import { CredentialBroker } from "../../../src/services/credentials/credential-broker.js"; +import { AutomationAuditExportService } from "../../../src/services/automation-audit-export-service.js"; +import { CustomDashboardCredentialBindingService } from "../../../src/services/custom-dashboard-credential-binding-service.js"; const tempDirs: string[] = []; @@ -25,6 +31,20 @@ function manifest(title = "Delivery Pulse"): CustomDashboardManifest { }; } +function credentialManifest(title = "Delivery Pulse"): CustomDashboardManifest { + return { + ...manifest(title), + credentialSlots: [{ + slotId: "metrics_api", + label: "Metrics API", + phase: "runtime", + required: true, + allowedKinds: ["http.token"], + requiredCapabilities: ["metrics.read"], + }], + }; +} + function fileBundle(content = "export default function Dashboard() { return null; }"): CustomDashboardFileBundle { return { files: [{ path: "src/dashboard.tsx", content, contentType: "text/typescript-jsx" }], @@ -39,6 +59,8 @@ async function createFixture(): Promise<{ handler: ManagementToolHandler; repository: CustomDashboardRepository; validationService: CustomDashboardValidationService; + bindingService: CustomDashboardCredentialBindingService; + credentialBroker: CredentialBroker; projectId: string; }> { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "custom-dashboard-mcp-")); @@ -51,8 +73,26 @@ async function createFixture(): Promise<{ sourceRef: dir, }); const repository = new CustomDashboardRepository(storage); + const credentialRepository = new AutomationCredentialRepository(storage); + const keyPath = path.join(dir, "credential-root.key"); + await fs.writeFile(keyPath, Buffer.alloc(32, 9).toString("base64"), { mode: 0o600 }); + const keyProvider = new MountedKeyFileProvider(keyPath); + const auditService = new AutomationAuditExportService(storage); + const credentialBroker = new CredentialBroker( + credentialRepository, + new EncryptedSqliteSecretStore(credentialRepository, keyProvider), + keyProvider, + auditService, + ); + const bindingService = new CustomDashboardCredentialBindingService({ + customDashboardRepository: repository, + projectManagementRepository: projects, + credentialBroker, + auditService, + }); const validationService = new CustomDashboardValidationService({ customDashboardRepository: repository, + customDashboardCredentialBindingService: bindingService, projectManagementRepository: projects, settingsRepository: new SettingsRepository(path.join(dir, "settings.db")), readinessPollMs: 1, @@ -60,6 +100,7 @@ async function createFixture(): Promise<{ }); const handler = new ManagementToolHandler({ customDashboardRepository: repository, + customDashboardCredentialBindingService: bindingService, customDashboardValidationService: validationService, projectManagementRepository: projects, sprintPreviewService: {}, @@ -79,7 +120,7 @@ async function createFixture(): Promise<{ planningAgentService: {}, sprintIssueService: {}, } as any); - return { handler, repository, validationService, projectId: project.id }; + return { handler, repository, validationService, bindingService, credentialBroker, projectId: project.id }; } function parseResponse(response: { content: Array<{ text: string }> }): Record { @@ -238,4 +279,112 @@ describe("manage_custom_dashboards", () => { })); expect(archived.result.dashboard.status).toBe("archived"); }); + + it("lists metadata-only slots and approval-gates project-owned bind and unbind actions", async () => { + const canary = "CUSTOM_DASHBOARD_REAL_SECRET_CANARY_7f8d9a"; + const { handler, repository, credentialBroker, projectId } = await createFixture(); + const dashboard = repository.createDraft(projectId, { + title: "Credential dashboard", + manifest: credentialManifest(), + fileBundle: fileBundle(), + }); + const credential = await credentialBroker.create(projectId, { + name: "Metrics", + kind: "http.token", + value: canary, + scope: "project", + allowedProjectIds: [], + capabilities: ["metrics.read"], + }); + + const listed = parseResponse(await handler.handleManageCustomDashboards({ + action: "list_credential_slots", + projectId, + dashboardId: dashboard.id, + })); + expect(listed.result.bindings).toMatchObject({ valid: false, credentialBindingRevision: 1 }); + expect(listed.result.bindings.slots[0].candidates).toEqual(expect.arrayContaining([ + expect.objectContaining({ credentialId: credential.id, compatible: true }), + ])); + expect(JSON.stringify(listed)).not.toContain(canary); + + const bindArgs = { + action: "bind_credential" as const, + projectId, + dashboardId: dashboard.id, + slotId: "metrics_api", + credentialId: credential.id, + expectedBindingRevision: 1, + }; + const preflight = parseResponse(await handler.handleManageCustomDashboards(bindArgs)); + expect(preflight.approvalRequired).toBe(true); + expect(repository.getDashboardById(dashboard.id)?.credentialBindings).toEqual([]); + + const bound = parseResponse(await handler.handleManageCustomDashboards({ + ...bindArgs, + approval: { confirmed: true }, + })); + expect(bound.result.bindings).toMatchObject({ valid: true, credentialBindingRevision: 2 }); + expect(JSON.stringify(bound)).not.toContain(canary); + + const generic = parseResponse(await handler.handleManageCustomDashboards({ action: "get", dashboardId: dashboard.id })); + expect(JSON.stringify(generic)).not.toContain("credentialBindings"); + expect(JSON.stringify(generic)).not.toContain(credential.id); + + const unbindArgs = { + action: "unbind_credential" as const, + projectId, + dashboardId: dashboard.id, + slotId: "metrics_api", + expectedBindingRevision: 2, + }; + expect(parseResponse(await handler.handleManageCustomDashboards(unbindArgs)).approvalRequired).toBe(true); + const unbound = parseResponse(await handler.handleManageCustomDashboards({ + ...unbindArgs, + approval: { confirmed: true }, + })); + expect(unbound.result.bindings).toMatchObject({ valid: false, credentialBindingRevision: 3 }); + expect(repository.getDashboardById(dashboard.id)?.credentialBindings).toEqual([]); + }); + + it("rejects secret-bearing bind fields and cross-project dashboard binding access", async () => { + const canary = "MCP_REJECTED_BIND_SECRET_CANARY"; + const { handler, repository, credentialBroker, projectId } = await createFixture(); + const dashboard = repository.createDraft(projectId, { + title: "Credential dashboard", + manifest: credentialManifest(), + fileBundle: fileBundle(), + }); + const credential = await credentialBroker.create(projectId, { + name: "Metrics", + kind: "http.token", + value: canary, + scope: "project", + allowedProjectIds: [], + capabilities: ["metrics.read"], + }); + const unsafe = { + action: "bind_credential" as const, + projectId, + dashboardId: dashboard.id, + slotId: "metrics_api", + credentialId: credential.id, + expectedBindingRevision: 1, + value: canary, + }; + await handler.handleManageCustomDashboards(unsafe as any); + const denied = parseResponse(await handler.handleManageCustomDashboards({ + ...unsafe, + approval: { confirmed: true }, + } as any)); + expect(denied.result.status).toBe("error"); + expect(JSON.stringify(denied)).not.toContain(canary); + + const crossProject = parseResponse(await handler.handleManageCustomDashboards({ + action: "list_credential_slots", + projectId: "another-project", + dashboardId: dashboard.id, + })); + expect(crossProject.result.status).toBe("error"); + }); }); diff --git a/tests/backend/server/custom-dashboard-routes.test.ts b/tests/backend/server/custom-dashboard-routes.test.ts index fd45c3266a..7f97f4ec70 100644 --- a/tests/backend/server/custom-dashboard-routes.test.ts +++ b/tests/backend/server/custom-dashboard-routes.test.ts @@ -15,6 +15,13 @@ import { CustomDashboardRepository } from "../../../src/repositories/custom-dash import { ProjectManagementRepository } from "../../../src/repositories/project-management-repository.js"; import { SettingsRepository } from "../../../src/repositories/settings-repository.js"; import { CustomDashboardValidationService } from "../../../src/services/custom-dashboard-validation-service.js"; +import { AutomationCredentialRepository } from "../../../src/repositories/automation-credential-repository.js"; +import { MountedKeyFileProvider } from "../../../src/infrastructure/security/mounted-key-file-provider.js"; +import { EncryptedSqliteSecretStore } from "../../../src/infrastructure/security/encrypted-sqlite-secret-store.js"; +import { CredentialBroker } from "../../../src/services/credentials/credential-broker.js"; +import { AutomationAuditExportService } from "../../../src/services/automation-audit-export-service.js"; +import { CustomDashboardCredentialBindingService } from "../../../src/services/custom-dashboard-credential-binding-service.js"; +import { requiredRoleForDashboardRequest } from "../../../src/services/headless-auth-service.js"; const tempDirs: string[] = []; @@ -27,6 +34,20 @@ function manifest(title = "Delivery Pulse"): CustomDashboardManifest { }; } +function credentialManifest(title = "Delivery Pulse"): CustomDashboardManifest { + return { + ...manifest(title), + credentialSlots: [{ + slotId: "metrics_api", + label: "Metrics API", + phase: "runtime", + required: true, + allowedKinds: ["http.token"], + requiredCapabilities: ["metrics.read"], + }], + }; +} + function fileBundle(content = "export default function Dashboard() { return null; }"): CustomDashboardFileBundle { return { files: [{ path: "src/dashboard.tsx", content, contentType: "text/typescript-jsx" }], @@ -42,6 +63,8 @@ async function createFixture(fetchImpl: typeof fetch = fetch): Promise<{ dir: string; repository: CustomDashboardRepository; validationService: CustomDashboardValidationService; + bindingService: CustomDashboardCredentialBindingService; + credentialBroker: CredentialBroker; projectId: string; }> { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "custom-dashboard-routes-")); @@ -54,8 +77,25 @@ async function createFixture(fetchImpl: typeof fetch = fetch): Promise<{ sourceRef: dir, }); const repository = new CustomDashboardRepository(storage); + const credentialRepository = new AutomationCredentialRepository(storage); + const keyPath = path.join(dir, "credential-root.key"); + await fs.writeFile(keyPath, Buffer.alloc(32, 7).toString("base64"), { mode: 0o600 }); + const keyProvider = new MountedKeyFileProvider(keyPath); + const credentialBroker = new CredentialBroker( + credentialRepository, + new EncryptedSqliteSecretStore(credentialRepository, keyProvider), + keyProvider, + new AutomationAuditExportService(storage), + ); + const bindingService = new CustomDashboardCredentialBindingService({ + customDashboardRepository: repository, + projectManagementRepository: projects, + credentialBroker, + auditService: new AutomationAuditExportService(storage), + }); const validationService = new CustomDashboardValidationService({ customDashboardRepository: repository, + customDashboardCredentialBindingService: bindingService, projectManagementRepository: projects, settingsRepository: new SettingsRepository(path.join(dir, "settings.db")), fetchImpl, @@ -66,9 +106,10 @@ async function createFixture(fetchImpl: typeof fetch = fetch): Promise<{ app.use(express.json()); registerCustomDashboardRoutes(app, { customDashboardRepository: repository, + customDashboardCredentialBindingService: bindingService, customDashboardValidationService: validationService, } as any); - return { app, dir, repository, validationService, projectId: project.id }; + return { app, dir, repository, validationService, bindingService, credentialBroker, projectId: project.id }; } afterEach(async () => { @@ -229,4 +270,108 @@ describe("custom dashboard routes", () => { expect(response.headers.location).toBe(`/api/custom-dashboard-validations/${session.id}/proxy/next`); expect(response.text).toContain(`/api/custom-dashboard-validations/${session.id}/proxy/asset.js`); }); + + it("binds, replaces, reviews, and unbinds credentials through metadata-only optimistic routes", async () => { + const canary = "CUSTOM_DASHBOARD_REAL_SECRET_CANARY_7f8d9a"; + const { app, repository, credentialBroker, projectId } = await createFixture(); + const dashboard = repository.createDraft(projectId, { + title: "Credential dashboard", + manifest: credentialManifest(), + fileBundle: fileBundle(), + }); + const first = await credentialBroker.create(projectId, { + name: "Metrics one", + kind: "http.token", + value: canary, + scope: "project", + allowedProjectIds: [], + capabilities: ["metrics.read"], + }); + const second = await credentialBroker.create(projectId, { + name: "Metrics two", + kind: "http.token", + value: `${canary}_REPLACEMENT`, + scope: "project", + allowedProjectIds: [], + capabilities: ["metrics.read"], + }); + const route = `/api/projects/${projectId}/custom-dashboards/${dashboard.id}/credential-bindings`; + + const bound = await request(app).put(route).send({ + slotId: "metrics_api", + credentialId: first.id, + expectedBindingRevision: 1, + }); + expect(bound.status).toBe(200); + expect(bound.body).toMatchObject({ valid: true, credentialBindingRevision: 2 }); + expect(bound.body.slots[0].binding).toEqual({ slotId: "metrics_api", credentialId: first.id }); + expect(JSON.stringify(bound.body)).not.toContain(canary); + + const reviewed = await request(app).get(route); + expect(reviewed.status).toBe(200); + expect(reviewed.body.slots[0].candidates).toEqual(expect.arrayContaining([ + expect.objectContaining({ credentialId: first.id, compatible: true }), + expect.objectContaining({ credentialId: second.id, compatible: true }), + ])); + + const replaced = await request(app).put(route).send({ + slotId: "metrics_api", + credentialId: second.id, + expectedBindingRevision: 2, + }); + expect(replaced.body).toMatchObject({ valid: true, credentialBindingRevision: 3 }); + expect(replaced.body.slots[0].binding.credentialId).toBe(second.id); + + const stale = await request(app).put(route).send({ + slotId: "metrics_api", + credentialId: first.id, + expectedBindingRevision: 2, + }); + expect(stale.status).toBe(409); + + const rejectedSecretField = await request(app).put(route).send({ + slotId: "metrics_api", + credentialId: first.id, + expectedBindingRevision: 3, + value: canary, + }); + expect(rejectedSecretField.status).toBe(400); + expect(JSON.stringify(rejectedSecretField.body)).not.toContain(canary); + + const generic = await request(app).get(`/api/custom-dashboards/${dashboard.id}`); + expect(JSON.stringify(generic.body)).not.toContain("credentialBindings"); + expect(JSON.stringify(generic.body)).not.toContain(second.id); + + const revision = repository.createRevision(dashboard.id); + const validation = repository.createValidationSession(revision.id, { + status: "passed", + validationReport: passedReport(), + finishedAt: new Date().toISOString(), + }); + credentialBroker.revoke(projectId, second.id, { expectedVersion: second.version }); + const deniedPublication = await request(app) + .post(`/api/custom-dashboards/${dashboard.id}/revisions/${revision.id}/publish`) + .send({ validationSessionId: validation.id }); + expect(deniedPublication.status).toBe(400); + expect(deniedPublication.body.error).toContain("not active"); + expect(JSON.stringify(deniedPublication.body)).not.toContain(second.id); + expect(repository.getDashboardById(dashboard.id)?.publishedRevisionId).toBeNull(); + + const unbound = await request(app) + .delete(`${route}/metrics_api`) + .send({ expectedBindingRevision: 3 }); + expect(unbound.status).toBe(200); + expect(unbound.body).toMatchObject({ valid: false, credentialBindingRevision: 4 }); + expect(unbound.body.issues).toEqual([ + expect.objectContaining({ field: "credentialBindings.metrics_api", code: "required_binding_missing" }), + ]); + expect(JSON.stringify(unbound.body)).not.toContain(canary); + }); + + it("classifies custom dashboard credential-binding routes as credential administration", () => { + expect(requiredRoleForDashboardRequest({ + path: "/api/projects/project-1/custom-dashboards/dashboard-1/credential-bindings", + method: "GET", + } as any)).toBe("credential_admin"); + }); }); diff --git a/tests/backend/services/custom-dashboard-validation-service.test.ts b/tests/backend/services/custom-dashboard-validation-service.test.ts index 3f00c3ad97..f5cbb7e569 100644 --- a/tests/backend/services/custom-dashboard-validation-service.test.ts +++ b/tests/backend/services/custom-dashboard-validation-service.test.ts @@ -13,6 +13,12 @@ import { ProjectManagementRepository } from "../../../src/repositories/project-m import { SettingsRepository } from "../../../src/repositories/settings-repository.js"; import { CustomDashboardValidationService } from "../../../src/services/custom-dashboard-validation-service.js"; import { runCommandStrict } from "../../../src/services/cli-process-runner.js"; +import { AutomationCredentialRepository } from "../../../src/repositories/automation-credential-repository.js"; +import { MountedKeyFileProvider } from "../../../src/infrastructure/security/mounted-key-file-provider.js"; +import { EncryptedSqliteSecretStore } from "../../../src/infrastructure/security/encrypted-sqlite-secret-store.js"; +import { CredentialBroker } from "../../../src/services/credentials/credential-broker.js"; +import { AutomationAuditExportService } from "../../../src/services/automation-audit-export-service.js"; +import { CustomDashboardCredentialBindingService } from "../../../src/services/custom-dashboard-credential-binding-service.js"; vi.mock("../../../src/services/cli-process-runner.js", () => ({ runCommandStrict: vi.fn(), @@ -30,6 +36,11 @@ async function createFixture(): Promise<{ projects: ProjectManagementRepository; dashboards: CustomDashboardRepository; service: CustomDashboardValidationService; + bindingService: CustomDashboardCredentialBindingService; + credentialBroker: CredentialBroker; + secretStore: EncryptedSqliteSecretStore; + auditService: AutomationAuditExportService; + keyProvider: MountedKeyFileProvider; projectId: string; dashboardId: string; revisionId: string; @@ -44,6 +55,19 @@ async function createFixture(): Promise<{ sourceRef: dir, }); const dashboards = new CustomDashboardRepository(storage); + const credentialRepository = new AutomationCredentialRepository(storage); + const keyPath = path.join(dir, "credential-root.key"); + await fs.writeFile(keyPath, Buffer.alloc(32, 11).toString("base64"), { mode: 0o600 }); + const keyProvider = new MountedKeyFileProvider(keyPath); + const secretStore = new EncryptedSqliteSecretStore(credentialRepository, keyProvider); + const auditService = new AutomationAuditExportService(storage); + const credentialBroker = new CredentialBroker(credentialRepository, secretStore, keyProvider, auditService); + const bindingService = new CustomDashboardCredentialBindingService({ + customDashboardRepository: dashboards, + projectManagementRepository: projects, + credentialBroker, + auditService, + }); const dashboard = dashboards.createDraft(project.id, { title: "Delivery Pulse", manifest: manifest(), @@ -57,6 +81,7 @@ async function createFixture(): Promise<{ const revision = dashboards.createRevision(dashboard.id); const service = new CustomDashboardValidationService({ customDashboardRepository: dashboards, + customDashboardCredentialBindingService: bindingService, projectManagementRepository: projects, settingsRepository: new SettingsRepository(path.join(dir, "settings.db")), fetchImpl: vi.fn().mockResolvedValue(new Response("ok", { status: 200 })), @@ -73,6 +98,11 @@ async function createFixture(): Promise<{ projects, dashboards, service, + bindingService, + credentialBroker, + secretStore, + auditService, + keyProvider, projectId: project.id, dashboardId: dashboard.id, revisionId: revision.id, @@ -276,4 +306,223 @@ describe("CustomDashboardValidationService", () => { await expect(service.listValidationSessions(projectId)).resolves.toEqual([expect.objectContaining({ id: session.id })]); await expect(service.listValidationSessions(projectId, dashboardId)).resolves.toEqual([expect.objectContaining({ id: session.id })]); }); + + it("keeps a real bound credential canary out of validation artifacts, commands, reports, logs, and audits", async () => { + const canary = "CUSTOM_DASHBOARD_REAL_SECRET_CANARY_7f8d9a"; + const { + dir, + dashboards, + service, + bindingService, + credentialBroker, + secretStore, + auditService, + projectId, + } = await createFixture(); + const dashboard = dashboards.createDraft(projectId, { + title: "Bound dashboard", + manifest: { + ...manifest(), + credentialSlots: [{ + slotId: "metrics_api", + label: "Metrics API", + phase: "runtime", + required: true, + allowedKinds: ["http.token"], + requiredCapabilities: ["metrics.read"], + }], + }, + fileBundle: fileBundle(), + }); + const credential = await credentialBroker.create(projectId, { + name: "Metrics", + kind: "http.token", + value: canary, + scope: "project", + allowedProjectIds: [], + capabilities: ["metrics.read"], + }); + const secretRead = vi.spyOn(secretStore, "get"); + await bindingService.bindCredential(projectId, dashboard.id, { + slotId: "metrics_api", + credentialId: credential.id, + expectedBindingRevision: 1, + }); + const revision = dashboards.createRevision(dashboard.id); + const session = await service.startValidation(projectId, dashboard.id, revision.id); + + expect(session.status).toBe("passed"); + expect(secretRead).not.toHaveBeenCalled(); + const workspacePath = path.join(dir, ".code-ux", "runtime", "custom-dashboards", dashboard.id, revision.id, "workspace"); + const workspaceText = await readDirectoryText(workspacePath); + const dockerCapture = JSON.stringify(vi.mocked(runCommandStrict).mock.calls); + const logs = (await service.getValidationLogs(session.id)).logs; + const report = JSON.stringify(session); + const audits = auditService.exportNdjson({ projectId }); + for (const captured of [workspaceText, dockerCapture, logs, report, audits]) { + expect(captured).not.toContain(canary); + } + expect(workspaceText).not.toContain(credential.id); + expect(dockerCapture).not.toContain(credential.id); + expect(report).not.toContain(credential.id); + expect(logs).not.toContain(credential.id); + const bindingAudit = auditService.list({ projectId }).find((record) => record.action === "custom_dashboard.credential.bind"); + expect(bindingAudit).toMatchObject({ + projectId, + resourceId: dashboard.id, + outcome: "succeeded", + metadata: { + dashboardId: dashboard.id, + revision: 2, + slotId: "metrics_api", + credentialId: credential.id, + denialReason: null, + }, + }); + expect(Object.keys(bindingAudit?.metadata ?? {}).sort()).toEqual([ + "credentialId", + "dashboardId", + "denialReason", + "revision", + "slotId", + ]); + + credentialBroker.revoke(projectId, credential.id, { expectedVersion: credential.version }); + vi.mocked(runCommandStrict).mockClear(); + const denied = await service.startValidation(projectId, dashboard.id, revision.id); + expect(denied).toMatchObject({ + status: "failed", + validationReport: { + valid: false, + issues: expect.arrayContaining([ + expect.objectContaining({ field: "credentialBindings.metrics_api", code: "not_active" }), + ]), + }, + }); + expect(JSON.stringify(denied)).not.toContain(credential.id); + expect(vi.mocked(runCommandStrict)).not.toHaveBeenCalled(); + expect(secretRead).not.toHaveBeenCalled(); + }); + + it("allows optional unbound slots and fails closed for every required binding policy dimension", async () => { + const { + dashboards, + service, + credentialBroker, + keyProvider, + projects, + projectId, + dir, + } = await createFixture(); + const slot = (overrides: Partial[number]> = {}) => ({ + slotId: "metrics_api", + label: "Metrics API", + phase: "runtime" as const, + required: true, + allowedKinds: ["http.token"], + requiredCapabilities: ["metrics.read"], + ...overrides, + }); + const createRevision = ( + title: string, + declaration: ReturnType, + credentialId?: string, + ) => { + const dashboard = dashboards.createDraft(projectId, { + title, + manifest: { ...manifest(), title, credentialSlots: [declaration] }, + fileBundle: fileBundle(), + }); + if (credentialId) { + dashboards.updateCredentialBindings(dashboard.id, { + expectedBindingRevision: 1, + bindings: [{ slotId: declaration.slotId, credentialId }], + }); + } + return { dashboard, revision: dashboards.createRevision(dashboard.id) }; + }; + + const requiredMissing = createRevision("Required missing", slot()); + const requiredMissingSession = await service.startValidation(projectId, requiredMissing.dashboard.id, requiredMissing.revision.id); + expect(requiredMissingSession.validationReport?.issues).toEqual([ + expect.objectContaining({ code: "required_binding_missing", field: "credentialBindings.metrics_api" }), + ]); + + const optional = createRevision("Optional", slot({ required: false })); + expect((await service.startValidation(projectId, optional.dashboard.id, optional.revision.id)).status).toBe("passed"); + + const wrongKind = await credentialBroker.create(projectId, { + name: "Wrong kind", + kind: "ssh.key", + value: "wrong-kind-secret", + scope: "project", + allowedProjectIds: [], + capabilities: ["metrics.read"], + }); + const wrongKindRevision = createRevision("Wrong kind", slot(), wrongKind.id); + expect((await service.startValidation(projectId, wrongKindRevision.dashboard.id, wrongKindRevision.revision.id)) + .validationReport?.issues).toEqual(expect.arrayContaining([expect.objectContaining({ code: "kind_not_allowed" })])); + + const missingCapability = await credentialBroker.create(projectId, { + name: "Missing capability", + kind: "http.token", + value: "missing-capability-secret", + scope: "project", + allowedProjectIds: [], + capabilities: ["other.read"], + }); + const missingCapabilityRevision = createRevision("Missing capability", slot(), missingCapability.id); + expect((await service.startValidation(projectId, missingCapabilityRevision.dashboard.id, missingCapabilityRevision.revision.id)) + .validationReport?.issues).toEqual(expect.arrayContaining([expect.objectContaining({ code: "capability_missing" })])); + + const otherProject = projects.createProject({ + name: "Other project", + sourceType: "local", + sourceRef: path.join(dir, "other-project"), + }); + const inaccessible = await credentialBroker.create(otherProject.id, { + name: "Other project credential", + kind: "http.token", + value: "inaccessible-secret", + scope: "project", + allowedProjectIds: [], + capabilities: ["metrics.read"], + }); + const inaccessibleRevision = createRevision("Inaccessible", slot(), inaccessible.id); + expect((await service.startValidation(projectId, inaccessibleRevision.dashboard.id, inaccessibleRevision.revision.id)) + .validationReport?.issues).toEqual(expect.arrayContaining([expect.objectContaining({ code: "project_access_denied" })])); + + const backendCredential = await credentialBroker.create(projectId, { + name: "Backend health", + kind: "http.token", + value: "backend-health-secret", + scope: "project", + allowedProjectIds: [], + capabilities: ["metrics.read"], + }); + const backendRevision = createRevision("Backend unavailable", slot(), backendCredential.id); + vi.spyOn(keyProvider, "health").mockResolvedValue({ + available: false, + secure: true, + provider: keyProvider.providerName, + keyId: null, + keyVersion: null, + reason: "Test backend unavailable.", + }); + expect((await service.startValidation(projectId, backendRevision.dashboard.id, backendRevision.revision.id)) + .validationReport?.issues).toEqual(expect.arrayContaining([expect.objectContaining({ code: "backend_unavailable" })])); + }); }); + +async function readDirectoryText(root: string): Promise { + const chunks: string[] = []; + const visit = async (directory: string): Promise => { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(target); + else if (entry.isFile()) chunks.push(await fs.readFile(target, "utf8")); + } + }; + await visit(root); + return chunks.join("\n"); +} diff --git a/tests/backend/services/custom-dashboard-validation-utils.test.ts b/tests/backend/services/custom-dashboard-validation-utils.test.ts index 2a589d419a..bfe28fe91c 100644 --- a/tests/backend/services/custom-dashboard-validation-utils.test.ts +++ b/tests/backend/services/custom-dashboard-validation-utils.test.ts @@ -4,6 +4,7 @@ import * as os from "os"; import * as path from "path"; import type { CustomDashboardRevisionRecord } from "../../../src/contracts/custom-dashboard-types.js"; import { + buildBridgeConfig, materializeCustomDashboardWorkspace, resolveContainedCustomDashboardPath, } from "../../../src/services/custom-dashboard-validation-utils.js"; @@ -84,4 +85,70 @@ describe("custom dashboard validation filesystem utilities", () => { resolveContainedCustomDashboardPath(runtimeRoot, path.join(runtimeRoot, "linked-out", "artifact.js")), ).rejects.toThrow("must stay inside the custom dashboard runtime directory"); }); + + it("omits credential binding identifiers from generated bridge and workspace files", async () => { + const runtimeRoot = await mkTempDir("custom-dashboard-runtime-"); + const workspacePath = await resolveContainedCustomDashboardPath(runtimeRoot, path.join(runtimeRoot, "workspace")); + const credentialId = "credential-binding-id-canary"; + const boundRevision = revision({ + manifest: { + ...revision().manifest, + credentialSlots: [{ + slotId: "metrics_api", + label: "Metrics API", + phase: "runtime", + required: true, + allowedKinds: ["http.token"], + requiredCapabilities: ["metrics.read"], + }], + }, + credentialBindings: [{ slotId: "metrics_api", credentialId }], + runtimeMetadata: { + credentialBindings: [{ slotId: "metrics_api", credentialId }], + nested: { credentialId }, + }, + }); + const bridgeConfig = buildBridgeConfig(boundRevision); + + await materializeCustomDashboardWorkspace({ + revision: boundRevision, + workspacePath, + bridgeConfig, + }); + + expect(JSON.stringify(bridgeConfig)).not.toContain(credentialId); + expect(await readDirectoryText(workspacePath)).not.toContain(credentialId); + }); + + it("rejects generated source that embeds a bound credential identifier", async () => { + const runtimeRoot = await mkTempDir("custom-dashboard-runtime-"); + const workspacePath = await resolveContainedCustomDashboardPath(runtimeRoot, path.join(runtimeRoot, "workspace")); + const credentialId = "credential-binding-id-canary"; + const boundRevision = revision({ + fileBundle: { + files: [{ path: "src/dashboard.tsx", content: `export const embedded = ${JSON.stringify(credentialId)};` }], + }, + credentialBindings: [{ slotId: "metrics_api", credentialId }], + }); + + await expect(materializeCustomDashboardWorkspace({ + revision: boundRevision, + workspacePath, + bridgeConfig: buildBridgeConfig(boundRevision), + })).rejects.toThrow("cannot contain credential binding identifiers"); + await expect(fs.stat(workspacePath)).rejects.toMatchObject({ code: "ENOENT" }); + }); }); + +async function readDirectoryText(root: string): Promise { + const chunks: string[] = []; + const visit = async (directory: string): Promise => { + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const target = path.join(directory, entry.name); + if (entry.isDirectory()) await visit(target); + else if (entry.isFile()) chunks.push(await fs.readFile(target, "utf8")); + } + }; + await visit(root); + return chunks.join("\n"); +} From 92976edee1f7d2a7b7ac49c0cc72d9884202df4b Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 22:26:53 +0000 Subject: [PATCH 09/22] feat(task T06): implement via codex --- .../settings/AutomationCredentialManager.tsx | 511 +++++++++++++++++- .../AutomationCredentialManager.test.tsx | 266 ++++++++- .../panels/SettingsIntegrationsPanel.tsx | 120 +++- .../src/v2/hooks/use-settings-page-state.ts | 2 + .../automation-credential-api.test.ts | 93 +++- .../src/v2/lib/automation-credential-api.ts | 72 ++- docs/operations/credential-security.md | 6 + docs/settings/integrations.md | 21 +- .../v2/settings-integrations-panel.test.tsx | 72 +++ .../dashboard/v2/settings-page-state.test.tsx | 2 +- 10 files changed, 1111 insertions(+), 54 deletions(-) diff --git a/dashboard/src/v2/components/settings/AutomationCredentialManager.tsx b/dashboard/src/v2/components/settings/AutomationCredentialManager.tsx index 2e57e56682..caf67fc5b7 100644 --- a/dashboard/src/v2/components/settings/AutomationCredentialManager.tsx +++ b/dashboard/src/v2/components/settings/AutomationCredentialManager.tsx @@ -1,21 +1,494 @@ import type { FunctionComponent } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { KeyRound, Plus, RefreshCw, ShieldAlert } from "lucide-preact"; -import type { AutomationCredentialMetadata, CredentialBackendHealth } from "../../../../../src/contracts/automation-credential-types.js"; -import { createAutomationCredential, fetchAutomationCredentials, fetchCredentialHealth, revokeAutomationCredential, testAutomationCredential } from "../../lib/automation-credential-api.js"; - -export const AutomationCredentialManager:FunctionComponent<{projectId:string}> = ({projectId}) => { - const [credentials,setCredentials]=useState([]); const [health,setHealth]=useState(null); - const [name,setName]=useState(""); const [kind,setKind]=useState(""); const [value,setValue]=useState(""); const [busy,setBusy]=useState(false); const [error,setError]=useState(null); - const load=async()=>{setError(null);try{const [nextCredentials,nextHealth]=await Promise.all([fetchAutomationCredentials(projectId),fetchCredentialHealth()]);setCredentials(nextCredentials);setHealth(nextHealth);}catch(caught){setError(caught instanceof Error?caught.message:String(caught));}}; - useEffect(()=>{void load();},[projectId]); - const create=async()=>{setBusy(true);setError(null);try{await createAutomationCredential(projectId,{name,kind,value,scope:"project",allowedProjectIds:[],capabilities:["read"]});setName("");setKind("");setValue("");await load();}catch(caught){setError(caught instanceof Error?caught.message:String(caught));}finally{setBusy(false);}}; - return
-

Automation credentials

Values are write-only and encrypted before local persistence.

- {health&&!health.available?
{health.reason??"Secure key storage is unavailable. Credential writes are disabled."}
:null} - {error?
{error}
:null} -
- -
    {credentials.map((credential)=>
  • {credential.name}
    {credential.kind} · {credential.scope} · {credential.status} · v{credential.version}
  • )}
-
; +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; +import { + CheckCircle2, + KeyRound, + Loader2, + Pencil, + Plus, + RefreshCw, + RotateCw, + ShieldAlert, + ShieldCheck, + Trash2, +} from "lucide-preact"; +import type { + AutomationCredentialCapability, + AutomationCredentialMetadata, + AutomationCredentialScope, + CredentialBackendHealth, +} from "../../../../../src/contracts/automation-credential-types.js"; +import { + createAutomationCredential, + fetchAutomationCredentials, + fetchCredentialHealth, + promoteAutomationCredential, + replaceAutomationCredential, + restrictAutomationCredential, + revokeAutomationCredential, + rotateAutomationCredential, + testAutomationCredential, + toAutomationCredentialApiError, + updateAutomationCredential, +} from "../../lib/automation-credential-api.js"; +import { useConfirmDialog } from "../../hooks/use-confirm-dialog.js"; +import { ConfirmDialog } from "../ui/ConfirmDialog.js"; + +interface ProjectOption { + id: string; + name: string; +} + +interface AutomationCredentialManagerProps { + projectId: string; + projects?: ProjectOption[]; +} + +type Feedback = { tone: "success" | "error"; message: string }; + +const CAPABILITIES: ReadonlyArray<{ value: AutomationCredentialCapability; label: string; description: string }> = [ + { value: "read", label: "Read", description: "Allow consumers to read from the connected service." }, + { value: "write", label: "Write", description: "Allow consumers to create or change remote data." }, + { value: "admin", label: "Admin", description: "Allow explicitly approved administrative operations." }, +]; + +const inputClassName = "mt-1.5 w-full min-w-0 rounded-xl border border-black/10 bg-white px-3 py-2.5 text-sm text-slate-900 outline-none transition focus:border-signal-500/50 focus:ring-2 focus:ring-signal-500/20 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-void-900 dark:text-slate-100"; +const buttonClassName = "inline-flex min-h-9 items-center justify-center gap-2 rounded-xl border border-black/10 bg-white px-3 py-2 text-xs font-bold text-slate-700 transition hover:border-black/20 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-signal-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-45 dark:border-white/10 dark:bg-white/[0.05] dark:text-slate-200 dark:hover:bg-white/[0.09]"; + +const isBackendReady = (health: CredentialBackendHealth | null): boolean => Boolean( + health?.available + && health.secure + && typeof health.keyId === "string" + && health.keyId.length > 0 + && health.keyVersion !== null, +); + +const toggleValue = (values: string[], value: string): string[] => ( + values.includes(value) ? values.filter((entry) => entry !== value) : [...values, value] +); + +const FeedbackMessage: FunctionComponent<{ feedback?: Feedback }> = ({ feedback }) => feedback ? ( +

+ {feedback.message} +

+) : null; + +const CapabilityPicker: FunctionComponent<{ + legend: string; + values: string[]; + available?: string[]; + disabled?: boolean; + onChange: (values: string[]) => void; +}> = ({ legend, values, available, disabled, onChange }) => { + const options = available + ? CAPABILITIES.filter((option) => available.includes(option.value)) + : CAPABILITIES; + return ( +
+ {legend} +
+ {options.map((option) => ( + + ))} +
+
+ ); +}; + +const ProjectPicker: FunctionComponent<{ + legend: string; + projects: ProjectOption[]; + values: string[]; + requiredProjectId: string; + availableProjectIds?: string[]; + disabled?: boolean; + onChange: (values: string[]) => void; +}> = ({ legend, projects, values, requiredProjectId, availableProjectIds, disabled, onChange }) => ( +
+ {legend} +

The managing project is always retained. Select every additional project that may use this credential.

+
+ {projects.filter((project) => !availableProjectIds || availableProjectIds.includes(project.id)).map((project) => { + const required = project.id === requiredProjectId; + return ( + + ); + })} +
+
+); + +export const AutomationCredentialManager: FunctionComponent = ({ projectId, projects = [] }) => { + const [credentials, setCredentials] = useState([]); + const [health, setHealth] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [busyAction, setBusyAction] = useState(null); + const [feedback, setFeedback] = useState>({}); + const [createFeedback, setCreateFeedback] = useState(); + const [name, setName] = useState(""); + const [kind, setKind] = useState(""); + const [scope, setScope] = useState("project"); + const [value, setValue] = useState(""); + const [createCapabilities, setCreateCapabilities] = useState([]); + const [createAllowedProjects, setCreateAllowedProjects] = useState([projectId]); + const [nameDrafts, setNameDrafts] = useState>({}); + const [secretDrafts, setSecretDrafts] = useState>({}); + const [restrictionCapabilities, setRestrictionCapabilities] = useState>({}); + const [restrictionProjects, setRestrictionProjects] = useState>({}); + const [promotionProjects, setPromotionProjects] = useState>({}); + const secretContainerRef = useRef(null); + const confirm = useConfirmDialog(); + + const projectOptions = useMemo(() => { + const byId = new Map(projects.map((project) => [project.id, project])); + if (!byId.has(projectId)) byId.set(projectId, { id: projectId, name: "Selected project" }); + for (const credential of credentials) { + for (const allowedProjectId of credential.allowedProjectIds) { + if (!byId.has(allowedProjectId)) byId.set(allowedProjectId, { id: allowedProjectId, name: allowedProjectId }); + } + } + return [...byId.values()]; + }, [credentials, projectId, projects]); + + const clearSecretFields = useCallback((): void => { + setValue(""); + setSecretDrafts({}); + secretContainerRef.current?.querySelectorAll('input[type="password"]').forEach((input) => { + input.value = ""; + }); + }, []); + + const applyCredentials = useCallback((nextCredentials: AutomationCredentialMetadata[]): void => { + setCredentials(nextCredentials); + setNameDrafts(Object.fromEntries(nextCredentials.map((credential) => [credential.id, credential.name]))); + setRestrictionCapabilities(Object.fromEntries(nextCredentials.map((credential) => [credential.id, [...credential.capabilities]]))); + setRestrictionProjects(Object.fromEntries(nextCredentials.map((credential) => [credential.id, [...credential.allowedProjectIds]]))); + setPromotionProjects(Object.fromEntries(nextCredentials.map((credential) => [credential.id, [projectId]]))); + }, [projectId]); + + const load = useCallback(async (announce = false): Promise => { + setLoading(true); + setLoadError(null); + try { + const [nextCredentials, nextHealth] = await Promise.all([ + fetchAutomationCredentials(projectId), + fetchCredentialHealth(), + ]); + applyCredentials(nextCredentials); + setHealth(nextHealth); + if (announce) setCreateFeedback({ tone: "success", message: "Credential metadata refreshed." }); + } catch (error) { + setLoadError(toAutomationCredentialApiError(error).message); + } finally { + setLoading(false); + } + }, [applyCredentials, projectId]); + + useEffect(() => { + clearSecretFields(); + setCreateAllowedProjects([projectId]); + setFeedback({}); + setCreateFeedback(undefined); + void load(); + return () => { + secretContainerRef.current?.querySelectorAll('input[type="password"]').forEach((input) => { + input.value = ""; + }); + }; + }, [clearSecretFields, load, projectId]); + + const setCredentialFeedback = (credentialId: string, next: Feedback): void => { + setFeedback((current) => ({ ...current, [credentialId]: next })); + }; + + const runMutation = async ( + credential: AutomationCredentialMetadata, + action: string, + successMessage: string, + operation: () => Promise, + ): Promise => { + setBusyAction(`${credential.id}:${action}`); + setFeedback((current) => { + const next = { ...current }; + delete next[credential.id]; + return next; + }); + try { + const updated = await operation(); + applyCredentials(credentials.map((entry) => entry.id === updated.id ? updated : entry)); + setCredentialFeedback(credential.id, { tone: "success", message: successMessage }); + } catch (error) { + const apiError = toAutomationCredentialApiError(error); + if (apiError.code === "stale_version") await load(); + setCredentialFeedback(credential.id, { tone: "error", message: apiError.message }); + } finally { + setBusyAction(null); + } + }; + + const submitCreate = async (): Promise => { + setCreateFeedback(undefined); + try { + if (!name.trim()) { + setCreateFeedback({ tone: "error", message: "Enter a clear credential name." }); + return; + } + if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]*$/.test(kind.trim())) { + setCreateFeedback({ tone: "error", message: "Enter a kind using letters, numbers, dots, underscores, colons, or hyphens." }); + return; + } + if (!value) { + setCreateFeedback({ tone: "error", message: "Enter the write-only secret value." }); + return; + } + if (createCapabilities.length === 0) { + setCreateFeedback({ tone: "error", message: "Select at least one capability deliberately." }); + return; + } + if (scope === "global") { + const confirmed = await confirm.requestConfirm({ + title: "Create a globally accessible credential?", + body: "Every selected project will be able to use this credential. The selected project remains the only management owner.", + confirmLabel: "Create global credential", + tone: "warning", + }); + if (!confirmed) return; + } + setBusyAction("create"); + await createAutomationCredential(projectId, { + name: name.trim(), + kind: kind.trim(), + value, + scope, + allowedProjectIds: scope === "global" ? [...new Set([projectId, ...createAllowedProjects])] : [], + capabilities: createCapabilities, + }); + setName(""); + setKind(""); + setScope("project"); + setCreateCapabilities([]); + setCreateAllowedProjects([projectId]); + setCreateFeedback({ tone: "success", message: "Credential stored. Its secret value is no longer present in this page." }); + await load(); + } catch (error) { + const apiError = toAutomationCredentialApiError(error); + setCreateFeedback({ tone: "error", message: apiError.message }); + } finally { + clearSecretFields(); + setBusyAction(null); + } + }; + + const confirmMutation = async (options: Parameters[0], operation: () => Promise): Promise => { + const trigger = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const restoreFocus = (): void => { + const triggerDisabled = trigger instanceof HTMLButtonElement && trigger.disabled; + const target = !triggerDisabled + ? trigger + : trigger?.closest("li")?.querySelector('input:not(:disabled), button:not(:disabled)') ?? null; + target?.focus({ preventScroll: true }); + }; + try { + if (await confirm.requestConfirm(options)) await operation(); + } finally { + window.setTimeout(restoreFocus, 0); + // ConfirmDialog completes its exit animation after the promise resolves. + // Re-apply focus after that cleanup so a re-rendered lifecycle control wins over the portal fallback. + window.setTimeout(restoreFocus, 400); + } + }; + + const backendReady = isBackendReady(health); + const configuredCount = credentials.filter((credential) => credential.configured && credential.status === "active").length; + + return ( +
+
+
+
+

Automation credential management

+ {!loading && backendReady ? ( + Secure storage ready + ) : null} +
+

Create project-owned or explicitly allowlisted credentials. Secret values are write-only; this page retains only non-secret metadata.

+
+ +
+ + {loading && credentials.length === 0 ? ( +
Loading credential health and project-visible metadata…
+ ) : null} + {loadError ? ( +
{loadError}
+ ) : null} + {!loading && health && !backendReady ? ( +
+ +
Secure credential storage is unavailable

{health.reason || "Restore the configured secure key provider and refresh this page. Existing metadata remains visible, but secret-bearing changes and tests are disabled."}

+
+ ) : null} + {!loading && backendReady && credentials.length === 0 ? ( +
Ready, not configured. Store the first credential below and grant only the capabilities its automation consumer requires.
+ ) : null} + {!loading && backendReady && configuredCount > 0 ? ( +
Configured. {configuredCount} active credential{configuredCount === 1 ? " is" : "s are"} ready for compatible project automation.
+ ) : null} + +
+

Store a credential

+
+ + + + +
+
+ {scope === "global" ?
: null} + + +
+ +
+

Visible to this project

+ {credentials.length === 0 && !loading ?

No credential metadata is visible to this project.

: null} +
    + {credentials.map((credential) => { + const canManage = credential.managementProjectId === projectId; + const disabled = busyAction !== null || !canManage; + const secret = secretDrafts[credential.id] || ""; + const statusTone = credential.status === "active" && credential.configured ? "text-status-green" : "text-status-amber"; + return ( +
  • +
    +
    +
    {credential.name}
    {credential.status}
    +

    {credential.kind} · {credential.scope === "project" ? "Project-owned" : "Globally accessible"} · metadata version {credential.version}

    +
    + {canManage ? "Managed here" : "Use only"} +
    +
    +
    Capabilities{credential.capabilities.length ? credential.capabilities.join(", ") : "None"}
    +
    Project access{credential.scope === "project" ? "Owning project only" : credential.allowedProjectIds.join(", ") || "No projects"}
    +
    + {!canManage ?

    This globally accessible credential is managed by another project. You may use it where compatible, but management actions are disabled here.

    : null} + +
    +
    + +
    +
    + +
    + +
    + + +
    +
    +
    + + {canManage && credential.status !== "revoked" ? ( +
    + setRestrictionCapabilities((current) => ({ ...current, [credential.id]: values }))} disabled={busyAction !== null} /> + {credential.scope === "global" ?
    setRestrictionProjects((current) => ({ ...current, [credential.id]: values }))} disabled={busyAction !== null} />
    : null} + +
    + ) : null} + + {canManage && credential.scope === "project" && credential.status === "active" ? ( +
    + setPromotionProjects((current) => ({ ...current, [credential.id]: values }))} disabled={busyAction !== null || !backendReady} /> + +
    + ) : null} + +
    + + {busyAction?.startsWith(`${credential.id}:`) ? Applying credential change… : null} +
    + +
  • + ); + })} +
+
+ + +
+ ); }; diff --git a/dashboard/src/v2/components/settings/__tests__/AutomationCredentialManager.test.tsx b/dashboard/src/v2/components/settings/__tests__/AutomationCredentialManager.test.tsx index e40c582b8c..9bec124201 100644 --- a/dashboard/src/v2/components/settings/__tests__/AutomationCredentialManager.test.tsx +++ b/dashboard/src/v2/components/settings/__tests__/AutomationCredentialManager.test.tsx @@ -1,15 +1,261 @@ // @vitest-environment jsdom -import { render,screen,waitFor } from "@testing-library/preact"; -import { beforeEach,describe,expect,it,vi } from "vitest"; +import { cleanup, render, screen, waitFor, within } from "@testing-library/preact"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AutomationCredentialMetadata } from "../../../../../../src/contracts/automation-credential-types.js"; import { AutomationCredentialManager } from "../AutomationCredentialManager.js"; -import { fetchAutomationCredentials,fetchCredentialHealth } from "../../../lib/automation-credential-api.js"; +import { + createAutomationCredential, + fetchAutomationCredentials, + fetchCredentialHealth, + promoteAutomationCredential, + replaceAutomationCredential, + restrictAutomationCredential, + revokeAutomationCredential, + rotateAutomationCredential, + testAutomationCredential, + updateAutomationCredential, +} from "../../../lib/automation-credential-api.js"; -vi.mock("../../../lib/automation-credential-api.js",()=>({ - fetchAutomationCredentials:vi.fn(), fetchCredentialHealth:vi.fn(), createAutomationCredential:vi.fn(), - testAutomationCredential:vi.fn(), revokeAutomationCredential:vi.fn(), -})); +vi.mock("../../../lib/automation-credential-api.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchAutomationCredentials: vi.fn(), + fetchCredentialHealth: vi.fn(), + createAutomationCredential: vi.fn(), + updateAutomationCredential: vi.fn(), + testAutomationCredential: vi.fn(), + rotateAutomationCredential: vi.fn(), + replaceAutomationCredential: vi.fn(), + restrictAutomationCredential: vi.fn(), + promoteAutomationCredential: vi.fn(), + revokeAutomationCredential: vi.fn(), + }; +}); + +const credential = (overrides: Partial = {}): AutomationCredentialMetadata => ({ + id: "credential-1", + name: "Deployment token", + kind: "api-token", + scope: "project", + projectId: "project-1", + managementProjectId: "project-1", + allowedProjectIds: [], + capabilities: ["read", "write"], + status: "active", + configured: true, + keyId: "root", + keyVersion: 1, + version: 1, + lastValidatedAt: null, + validationStatus: "untested", + createdAt: "now", + updatedAt: "now", + ...overrides, +}); + +const readyHealth = { + available: true, + secure: true, + provider: "local-file", + keyId: "root", + keyVersion: 1, +}; + +const renderManager = () => render( + , +); + +describe("AutomationCredentialManager", () => { + afterEach(() => cleanup()); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchAutomationCredentials).mockResolvedValue([credential()]); + vi.mocked(fetchCredentialHealth).mockResolvedValue(readyHealth); + }); + + it("renders unavailable health safely and disables secret writes", async () => { + vi.mocked(fetchCredentialHealth).mockResolvedValue({ + available: false, + secure: false, + provider: "electron-safe-storage", + keyId: null, + keyVersion: null, + reason: "OS secure storage is unavailable.", + }); + + renderManager(); + + expect(await screen.findByText("Deployment token")).toBeTruthy(); + expect(screen.getByRole("alert").textContent).toContain("OS secure storage is unavailable."); + expect((screen.getByRole("button", { name: "Store credential" }) as HTMLButtonElement).disabled).toBe(true); + expect(document.body.textContent).not.toContain("plain-secret"); + expect(document.body.textContent).not.toContain("root"); + }); + + it("shows ready-unconfigured state, requires explicit capabilities, and clears create secrets after every outcome", async () => { + const user = userEvent.setup(); + vi.mocked(fetchAutomationCredentials).mockResolvedValue([]); + vi.mocked(createAutomationCredential).mockResolvedValue(credential()); + renderManager(); + + expect(await screen.findByText(/Ready, not configured/)).toBeTruthy(); + await user.type(screen.getByLabelText("Credential name"), "Deploy token"); + await user.type(screen.getByLabelText("Credential kind"), "api-token"); + const secretInput = screen.getByLabelText("Secret value") as HTMLInputElement; + await user.type(secretInput, "plain-secret"); + await user.click(screen.getByRole("button", { name: "Store credential" })); + + expect(await screen.findByText("Select at least one capability deliberately.")).toBeTruthy(); + expect(secretInput.value).toBe(""); + expect(createAutomationCredential).not.toHaveBeenCalled(); + + await user.type(secretInput, "next-secret"); + await user.click(screen.getByRole("checkbox", { name: /^Read/ })); + await user.click(screen.getByRole("button", { name: "Store credential" })); + + await waitFor(() => expect(createAutomationCredential).toHaveBeenCalledWith("project-1", { + name: "Deploy token", + kind: "api-token", + value: "next-secret", + scope: "project", + allowedProjectIds: [], + capabilities: ["read"], + })); + expect(secretInput.value).toBe(""); + expect(document.body.textContent).not.toContain("next-secret"); + }); + + it("renames and tests with current versions and typed inline success", async () => { + const user = userEvent.setup(); + vi.mocked(updateAutomationCredential).mockResolvedValue(credential({ name: "Release token", version: 2 })); + vi.mocked(testAutomationCredential).mockResolvedValue(credential({ name: "Release token", version: 3, validationStatus: "valid" })); + renderManager(); + + const renameInput = await screen.findByLabelText("Rename Deployment token"); + await user.clear(renameInput); + await user.type(renameInput, "Release token"); + await user.click(screen.getByRole("button", { name: "Save name" })); + await waitFor(() => expect(updateAutomationCredential).toHaveBeenCalledWith("project-1", "credential-1", { + name: "Release token", + expectedVersion: 1, + })); + expect(await screen.findByText("Credential name updated.")).toBeTruthy(); + + await user.click(screen.getByRole("button", { name: "Test" })); + await waitFor(() => expect(testAutomationCredential).toHaveBeenCalledWith("project-1", "credential-1", { expectedVersion: 2 })); + expect(await screen.findByText("Credential test passed.")).toBeTruthy(); + }); + + it("guards rotation with confirmation, clears the write-only field, and restores trigger focus", async () => { + const user = userEvent.setup(); + vi.mocked(rotateAutomationCredential).mockResolvedValue(credential({ version: 2 })); + renderManager(); + + const secretInput = await screen.findByLabelText("New secret for Deployment token") as HTMLInputElement; + await user.type(secretInput, "rotated-secret"); + const rotateButton = screen.getByRole("button", { name: "Rotate" }); + rotateButton.focus(); + await user.click(rotateButton); + expect(rotateAutomationCredential).not.toHaveBeenCalled(); + expect(screen.getByRole("dialog", { name: "Rotate Deployment token?" })).toBeTruthy(); + await user.click(screen.getByRole("button", { name: "Rotate value" })); + + await waitFor(() => expect(rotateAutomationCredential).toHaveBeenCalledWith("project-1", "credential-1", { + value: "rotated-secret", + expectedVersion: 1, + })); + expect(secretInput.value).toBe(""); + const item = rotateButton.closest("li") as HTMLElement; + await waitFor(() => expect(item.contains(document.activeElement)).toBe(true)); + }); + + it("confirms monotonic restriction, promotion, and revocation before lifecycle calls", async () => { + const user = userEvent.setup(); + vi.mocked(restrictAutomationCredential).mockResolvedValue(credential({ capabilities: ["read"], version: 2 })); + vi.mocked(promoteAutomationCredential).mockResolvedValue(credential({ + scope: "global", + projectId: null, + allowedProjectIds: ["project-1", "project-2"], + capabilities: ["read"], + version: 3, + })); + vi.mocked(revokeAutomationCredential).mockResolvedValue(credential({ + scope: "global", + projectId: null, + allowedProjectIds: ["project-1", "project-2"], + capabilities: ["read"], + status: "revoked", + version: 4, + })); + renderManager(); + + const item = (await screen.findByText("Deployment token")).closest("li") as HTMLElement; + const restrictionFieldset = within(item).getByText("Restrict capabilities").closest("fieldset") as HTMLElement; + await user.click(within(restrictionFieldset).getByRole("checkbox", { name: /^Write/ })); + await user.click(within(item).getByRole("button", { name: "Apply restriction" })); + expect(restrictAutomationCredential).not.toHaveBeenCalled(); + await user.click(within(screen.getByRole("dialog", { name: "Restrict Deployment token?" })).getByRole("button", { name: "Apply restriction" })); + await waitFor(() => expect(restrictAutomationCredential).toHaveBeenCalledWith("project-1", "credential-1", { + expectedVersion: 1, + capabilities: ["read"], + allowedProjectIds: [], + })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + + const promotionFieldset = within(item).getByText("Promote to global access").closest("fieldset") as HTMLElement; + await user.click(within(promotionFieldset).getByRole("checkbox", { name: /Allowed project/ })); + await user.click(within(item).getByRole("button", { name: "Promote credential" })); + expect(promoteAutomationCredential).not.toHaveBeenCalled(); + await user.click(within(screen.getByRole("dialog", { name: "Promote Deployment token to global access?" })).getByRole("button", { name: "Promote credential" })); + await waitFor(() => expect(promoteAutomationCredential).toHaveBeenCalledWith("project-1", "credential-1", { + expectedVersion: 2, + allowedProjectIds: ["project-1", "project-2"], + confirmScopeExpansion: true, + })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + + await user.click(within(item).getByRole("button", { name: "Revoke" })); + expect(revokeAutomationCredential).not.toHaveBeenCalled(); + await user.type(screen.getByLabelText("Type REVOKE to confirm"), "REVOKE"); + await user.click(screen.getByRole("button", { name: "Revoke credential" })); + await waitFor(() => expect(revokeAutomationCredential).toHaveBeenCalledWith("project-1", "credential-1", { expectedVersion: 3 })); + }); + + it("prevents management from an allowlisted non-owner project", async () => { + vi.mocked(fetchAutomationCredentials).mockResolvedValue([credential({ + scope: "global", + projectId: null, + managementProjectId: "project-2", + allowedProjectIds: ["project-1", "project-2"], + })]); + renderManager(); + + expect(await screen.findByText("Use only")).toBeTruthy(); + expect(screen.getByText(/managed by another project/)).toBeTruthy(); + expect((screen.getByRole("button", { name: "Save name" }) as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByRole("button", { name: "Test" }) as HTMLButtonElement).disabled).toBe(true); + }); + + it("clears a replacement secret when the API rejects a stale version", async () => { + const user = userEvent.setup(); + vi.mocked(replaceAutomationCredential).mockRejectedValue(new Error("Credential changed; refresh its metadata and retry with the current version.")); + renderManager(); + + const secretInput = await screen.findByLabelText("New secret for Deployment token") as HTMLInputElement; + await user.type(secretInput, "replacement-secret"); + await user.click(screen.getByRole("button", { name: "Replace" })); + await user.click(screen.getByRole("button", { name: "Replace value" })); -describe("AutomationCredentialManager",()=>{ - beforeEach(()=>{vi.clearAllMocks();vi.mocked(fetchAutomationCredentials).mockResolvedValue([{id:"credential-1",name:"Deployment token",kind:"api-token",scope:"project",projectId:"project-1",managementProjectId:"project-1",allowedProjectIds:[],capabilities:["read"],status:"active",configured:true,keyId:"root",keyVersion:1,version:1,lastValidatedAt:null,validationStatus:"untested",createdAt:"now",updatedAt:"now"}]);vi.mocked(fetchCredentialHealth).mockResolvedValue({available:false,secure:false,provider:"electron-safe-storage",keyId:null,keyVersion:null,reason:"OS secure storage is unavailable."});}); - it("renders metadata and disables secret writes when secure storage is unavailable",async()=>{render();expect(await screen.findByText("Deployment token")).toBeTruthy();expect(screen.getByRole("alert").textContent).toContain("OS secure storage is unavailable.");await waitFor(()=>expect((screen.getByRole("button",{name:"Store credential"}) as HTMLButtonElement).disabled).toBe(true));expect(document.body.textContent).not.toContain("plain-secret");}); + expect(await screen.findByText(/changed in another session/)).toBeTruthy(); + expect(secretInput.value).toBe(""); + }); }); diff --git a/dashboard/src/v2/components/settings/panels/SettingsIntegrationsPanel.tsx b/dashboard/src/v2/components/settings/panels/SettingsIntegrationsPanel.tsx index 34d745b166..f1ef9092be 100644 --- a/dashboard/src/v2/components/settings/panels/SettingsIntegrationsPanel.tsx +++ b/dashboard/src/v2/components/settings/panels/SettingsIntegrationsPanel.tsx @@ -1,4 +1,4 @@ -import type { FunctionComponent } from "preact"; +import type { FunctionComponent, Ref } from "preact"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks"; import gsap from "gsap"; import { Activity, AlertCircle, ArrowLeft, FolderOpen, Hash, Key, Link2, MessageCircle, Plug, Plus, RefreshCw, Save, Send, Settings2, ShieldCheck, Trash2 } from "lucide-preact"; @@ -50,6 +50,8 @@ import type { import { isDeprecatedProvider, providerLifecycle } from "../../../lib/provider-lifecycle.js"; import { LocalFilePickerField } from "../LocalFilePickerField.js"; import { AutomationCredentialManager } from "../AutomationCredentialManager.js"; +import type { AutomationCredentialMetadata, CredentialBackendHealth } from "../../../../../../src/contracts/automation-credential-types.js"; +import { fetchAutomationCredentials, fetchCredentialHealth } from "../../../lib/automation-credential-api.js"; type PublicProviderId = Exclude; @@ -303,8 +305,10 @@ const CatalogActionButton: FunctionComponent<{ onClick: () => void; disabled?: boolean; tone?: "primary" | "neutral"; -}> = ({ label, icon: Icon, onClick, disabled = false, tone = "neutral" }) => ( + buttonRef?: Ref; +}> = ({ label, icon: Icon, onClick, disabled = false, tone = "neutral", buttonRef }) => ( ); + if (integrationId === "automation-credentials") { + return ( + <> + {backButton} + } + helpId="integrations" + > + {state.selectedProject?.id ? ( + ({ id: project.id, name: project.name || project.id }))} + /> + ) : ( + + Automation credentials are listed and managed through a project so scope, allowlists, and management authority can be enforced. Select a project, then return here. + + )} + + + ); + } + if (isChatProviderIntegrationId(integrationId)) { return renderChatProviderDetail(integrationId); } @@ -1824,7 +1900,6 @@ export const SettingsIntegrationsPanel: FunctionComponent<{ state: SettingsPageS return (
- {state.selectedProject?.id ? : null}
{group.items.map((integration) => { + if (integration.id === "automation-credentials") { + const health = credentialCatalogState.health; + const backendReady = Boolean(health?.available && health.secure && health.keyId && health.keyVersion !== null); + const configuredCount = credentialCatalogState.credentials.filter((credential) => credential.configured && credential.status === "active").length; + const unavailable = credentialCatalogState.unavailable || (!credentialCatalogState.loading && !backendReady); + const statusLabel = credentialCatalogState.loading + ? "Checking status" + : unavailable + ? "Unavailable" + : configuredCount > 0 + ? `${configuredCount} configured` + : "Ready · not configured"; + return ( +
0 && !unavailable + ? "border-signal-500/24 bg-white/90 hover:border-signal-500/34 dark:border-signal-400/24 dark:bg-void-800/82" + : "border-black/[0.06] bg-white/88 hover:border-black/[0.12] hover:bg-white dark:border-white/[0.08] dark:bg-void-800/78" + }`}> +
0 && !unavailable ? "bg-signal-500 opacity-100" : "bg-slate-300 opacity-0 group-hover:opacity-100 dark:bg-slate-600"}`} /> +
+
+
+ +
+
{integration.label}
{configuredCount > 0 && !unavailable ? : null}
+
{integration.description}
+
+
+
+
0 ? "active" : "neutral"} />
+ setSelectedIntegration(integration.id)} /> +
+
+
+ ); + } + if (isChatProviderIntegrationId(integration.id)) { const providerKind = integration.id; const providerCard = chatProviderCards.find((card) => card.providerKind === providerKind); diff --git a/dashboard/src/v2/hooks/use-settings-page-state.ts b/dashboard/src/v2/hooks/use-settings-page-state.ts index 98951fb815..44a839c981 100644 --- a/dashboard/src/v2/hooks/use-settings-page-state.ts +++ b/dashboard/src/v2/hooks/use-settings-page-state.ts @@ -112,6 +112,7 @@ const routingProfileOptions = [ ]; type IntegrationId = + | "automation-credentials" | "jules" | "gemini" | "codex" @@ -139,6 +140,7 @@ interface IntegrationDefinition { } const INTEGRATIONS: IntegrationDefinition[] = [ + { id: "automation-credentials", label: "Automation Credentials", description: "Write-only secrets for project automation, node flows, and custom runtime integrations" }, { id: "jules", label: providerLabels.jules, description: providerDescriptions.jules }, { id: "gemini", label: providerLabels.gemini, description: providerDescriptions.gemini }, { id: "antigravity", label: providerLabels.antigravity, description: providerDescriptions.antigravity }, diff --git a/dashboard/src/v2/lib/__tests__/automation-credential-api.test.ts b/dashboard/src/v2/lib/__tests__/automation-credential-api.test.ts index 45803ff1ca..cd2570a970 100644 --- a/dashboard/src/v2/lib/__tests__/automation-credential-api.test.ts +++ b/dashboard/src/v2/lib/__tests__/automation-credential-api.test.ts @@ -1,5 +1,90 @@ -import { beforeEach,describe,expect,it,vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { fetchJson } from "../../../lib/api/fetch-json.js"; -import { createAutomationCredential,rotateAutomationCredential } from "../automation-credential-api.js"; -vi.mock("../../../lib/api/fetch-json.js",()=>({fetchJson:vi.fn()})); -describe("automation credential api",()=>{beforeEach(()=>vi.clearAllMocks());it("uses write-only create and rotate endpoints",async()=>{vi.mocked(fetchJson).mockResolvedValue({});const createInput={name:"Token",kind:"api-token",value:"secret",scope:"project" as const,allowedProjectIds:[],capabilities:["read"]};await createAutomationCredential("project/one",createInput);expect(fetchJson).toHaveBeenCalledWith("/api/projects/project%2Fone/credentials",expect.objectContaining({method:"POST",body:JSON.stringify(createInput)}));await rotateAutomationCredential("project/one","credential/one",{value:"next",expectedVersion:1});expect(fetchJson).toHaveBeenLastCalledWith("/api/projects/project%2Fone/credentials/credential%2Fone/rotate",expect.objectContaining({method:"POST",body:JSON.stringify({value:"next",expectedVersion:1})}));});}); +import { + AutomationCredentialApiError, + createAutomationCredential, + promoteAutomationCredential, + replaceAutomationCredential, + restrictAutomationCredential, + revokeAutomationCredential, + rotateAutomationCredential, + testAutomationCredential, + updateAutomationCredential, +} from "../automation-credential-api.js"; + +vi.mock("../../../lib/api/fetch-json.js", () => ({ fetchJson: vi.fn() })); + +describe("automation credential api", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fetchJson).mockResolvedValue({}); + }); + + it("uses encoded write-only create, rotate, and replace endpoints", async () => { + const createInput = { + name: "Token", + kind: "api-token", + value: "secret", + scope: "project" as const, + allowedProjectIds: [], + capabilities: ["read"], + }; + await createAutomationCredential("project/one", createInput); + expect(fetchJson).toHaveBeenCalledWith("/api/projects/project%2Fone/credentials", expect.objectContaining({ + method: "POST", + body: JSON.stringify(createInput), + })); + + await rotateAutomationCredential("project/one", "credential/one", { value: "next", expectedVersion: 1 }); + expect(fetchJson).toHaveBeenLastCalledWith("/api/projects/project%2Fone/credentials/credential%2Fone/rotate", expect.objectContaining({ + method: "POST", + body: JSON.stringify({ value: "next", expectedVersion: 1 }), + })); + + await replaceAutomationCredential("project/one", "credential/one", { value: "replacement", expectedVersion: 2 }); + expect(fetchJson).toHaveBeenLastCalledWith("/api/projects/project%2Fone/credentials/credential%2Fone/replace", expect.objectContaining({ + method: "POST", + body: JSON.stringify({ value: "replacement", expectedVersion: 2 }), + })); + }); + + it("passes current versions and backend policy fields to every metadata lifecycle endpoint", async () => { + await updateAutomationCredential("project", "credential", { name: "Renamed", expectedVersion: 3 }); + expect(fetchJson).toHaveBeenLastCalledWith("/api/projects/project/credentials/credential", expect.objectContaining({ method: "PATCH" })); + + await testAutomationCredential("project", "credential", { expectedVersion: 4 }); + expect(fetchJson).toHaveBeenLastCalledWith("/api/projects/project/credentials/credential/test", expect.objectContaining({ body: JSON.stringify({ expectedVersion: 4 }) })); + + const restriction = { allowedProjectIds: ["project"], capabilities: ["read"], expectedVersion: 5 }; + await restrictAutomationCredential("project", "credential", restriction); + expect(fetchJson).toHaveBeenLastCalledWith("/api/projects/project/credentials/credential/restrict", expect.objectContaining({ body: JSON.stringify(restriction) })); + + const promotion = { allowedProjectIds: ["project", "other"], expectedVersion: 6, confirmScopeExpansion: true }; + await promoteAutomationCredential("project", "credential", promotion); + expect(fetchJson).toHaveBeenLastCalledWith("/api/projects/project/credentials/credential/promote", expect.objectContaining({ body: JSON.stringify(promotion) })); + + await revokeAutomationCredential("project", "credential", { expectedVersion: 7 }); + expect(fetchJson).toHaveBeenLastCalledWith("/api/projects/project/credentials/credential/revoke", expect.objectContaining({ body: JSON.stringify({ expectedVersion: 7 }) })); + }); + + it("converts raw server failures into typed, non-raw stale and custody guidance", async () => { + vi.mocked(fetchJson).mockRejectedValueOnce(new Error("Credential changed; refresh its metadata and retry with the current version.")); + await expect(testAutomationCredential("project", "credential", { expectedVersion: 1 })).rejects.toMatchObject({ + code: "stale_version", + message: expect.stringContaining("changed in another session"), + }); + + vi.mocked(fetchJson).mockRejectedValueOnce(new Error("private vault adapter stack: key custody unavailable at /secret/path")); + const error = await createAutomationCredential("project", { + name: "Token", + kind: "api-token", + value: "secret", + scope: "project", + allowedProjectIds: [], + capabilities: ["read"], + }).catch((caught: unknown) => caught); + expect(error).toBeInstanceOf(AutomationCredentialApiError); + expect(error).toMatchObject({ code: "backend_unavailable" }); + expect((error as Error).message).not.toContain("/secret/path"); + }); +}); diff --git a/dashboard/src/v2/lib/automation-credential-api.ts b/dashboard/src/v2/lib/automation-credential-api.ts index 7c92489e27..cf32a2b0f0 100644 --- a/dashboard/src/v2/lib/automation-credential-api.ts +++ b/dashboard/src/v2/lib/automation-credential-api.ts @@ -14,6 +14,54 @@ import type { } from "../../../../src/contracts/automation-credential-types.js"; import { fetchJson } from "../../lib/api/fetch-json.js"; +export type AutomationCredentialApiErrorCode = + | "validation" + | "stale_version" + | "forbidden" + | "backend_unavailable" + | "invalid_state" + | "request_failed"; + +const ERROR_MESSAGES: Record = { + validation: "Check the required fields and credential policy, then try again.", + stale_version: "This credential changed in another session. Its metadata has been refreshed; review it and try again.", + forbidden: "The selected project can use this credential but does not have authority to manage it.", + backend_unavailable: "Secure credential storage is unavailable. Follow the setup guidance and try again after custody is restored.", + invalid_state: "The encrypted credential state cannot be read. Replace its secret value before retrying.", + request_failed: "The credential request could not be completed. Refresh the metadata and try again.", +}; + +export class AutomationCredentialApiError extends Error { + constructor(public readonly code: AutomationCredentialApiErrorCode) { + super(ERROR_MESSAGES[code]); + this.name = "AutomationCredentialApiError"; + } +} + +const classifyCredentialError = (error: unknown): AutomationCredentialApiErrorCode => { + const message = error instanceof Error ? error.message : ""; + if (/changed|current version|expectedVersion|concurrent/i.test(message)) return "stale_version"; + if (/forbidden|access denied|outside the project|only the managing|lacks management/i.test(message)) return "forbidden"; + if (/encrypted state/i.test(message)) return "invalid_state"; + if (/key custody|secure storage|credential broker|backend.*unavailable|storage.*unavailable/i.test(message)) return "backend_unavailable"; + if (/required|must |cannot |explicit|at most|unsupported fields|may contain/i.test(message)) return "validation"; + return "request_failed"; +}; + +export const toAutomationCredentialApiError = (error: unknown): AutomationCredentialApiError => ( + error instanceof AutomationCredentialApiError + ? error + : new AutomationCredentialApiError(classifyCredentialError(error)) +); + +const request = async (operation: () => Promise): Promise => { + try { + return await operation(); + } catch (error) { + throw toAutomationCredentialApiError(error); + } +}; + const json = (method: string, body?: unknown): RequestInit => ({ method, headers: { "Content-Type": "application/json" }, @@ -22,15 +70,15 @@ const json = (method: string, body?: unknown): RequestInit => ({ const base = (projectId: string) => `/api/projects/${encodeURIComponent(projectId)}/credentials`; const credential = (projectId: string, id: string) => `${base(projectId)}/${encodeURIComponent(id)}`; -export const fetchCredentialHealth = (): Promise => fetchJson("/api/credentials/health"); -export const fetchAutomationCredentials = (projectId: string): Promise => fetchJson(base(projectId)); -export const createAutomationCredential = (projectId: string, input: CreateAutomationCredentialInput): Promise => fetchJson(base(projectId), json("POST", input)); -export const updateAutomationCredential = (projectId: string, id: string, input: UpdateAutomationCredentialMetadataInput): Promise => fetchJson(credential(projectId, id), json("PATCH", input)); -export const bindAutomationCredential = (projectId: string, id: string, input: BindAutomationCredentialInput): Promise => fetchJson(`${credential(projectId, id)}/bind`, json("POST", input)); -export const assessAutomationCredentialCompatibility = (projectId: string, id: string, input: { allowedKinds: string[]; requiredCapabilities: string[] }): Promise => fetchJson(`${credential(projectId, id)}/compatibility`, json("POST", input)); -export const testAutomationCredential = (projectId: string, id: string, input: TestAutomationCredentialInput): Promise => fetchJson(`${credential(projectId, id)}/test`, json("POST", input)); -export const rotateAutomationCredential = (projectId: string, id: string, input: ReplaceAutomationCredentialSecretInput): Promise => fetchJson(`${credential(projectId, id)}/rotate`, json("POST", input)); -export const replaceAutomationCredential = (projectId: string, id: string, input: ReplaceAutomationCredentialSecretInput): Promise => fetchJson(`${credential(projectId, id)}/replace`, json("POST", input)); -export const revokeAutomationCredential = (projectId: string, id: string, input: RevokeAutomationCredentialInput): Promise => fetchJson(`${credential(projectId, id)}/revoke`, json("POST", input)); -export const promoteAutomationCredential = (projectId: string, id: string, input: PromoteAutomationCredentialInput): Promise => fetchJson(`${credential(projectId, id)}/promote`, json("POST", input)); -export const restrictAutomationCredential = (projectId: string, id: string, input: RestrictAutomationCredentialInput): Promise => fetchJson(`${credential(projectId, id)}/restrict`, json("POST", input)); +export const fetchCredentialHealth = (): Promise => request(() => fetchJson("/api/credentials/health")); +export const fetchAutomationCredentials = (projectId: string): Promise => request(() => fetchJson(base(projectId))); +export const createAutomationCredential = (projectId: string, input: CreateAutomationCredentialInput): Promise => request(() => fetchJson(base(projectId), json("POST", input))); +export const updateAutomationCredential = (projectId: string, id: string, input: UpdateAutomationCredentialMetadataInput): Promise => request(() => fetchJson(credential(projectId, id), json("PATCH", input))); +export const bindAutomationCredential = (projectId: string, id: string, input: BindAutomationCredentialInput): Promise => request(() => fetchJson(`${credential(projectId, id)}/bind`, json("POST", input))); +export const assessAutomationCredentialCompatibility = (projectId: string, id: string, input: { allowedKinds: string[]; requiredCapabilities: string[] }): Promise => request(() => fetchJson(`${credential(projectId, id)}/compatibility`, json("POST", input))); +export const testAutomationCredential = (projectId: string, id: string, input: TestAutomationCredentialInput): Promise => request(() => fetchJson(`${credential(projectId, id)}/test`, json("POST", input))); +export const rotateAutomationCredential = (projectId: string, id: string, input: ReplaceAutomationCredentialSecretInput): Promise => request(() => fetchJson(`${credential(projectId, id)}/rotate`, json("POST", input))); +export const replaceAutomationCredential = (projectId: string, id: string, input: ReplaceAutomationCredentialSecretInput): Promise => request(() => fetchJson(`${credential(projectId, id)}/replace`, json("POST", input))); +export const revokeAutomationCredential = (projectId: string, id: string, input: RevokeAutomationCredentialInput): Promise => request(() => fetchJson(`${credential(projectId, id)}/revoke`, json("POST", input))); +export const promoteAutomationCredential = (projectId: string, id: string, input: PromoteAutomationCredentialInput): Promise => request(() => fetchJson(`${credential(projectId, id)}/promote`, json("POST", input))); +export const restrictAutomationCredential = (projectId: string, id: string, input: RestrictAutomationCredentialInput): Promise => request(() => fetchJson(`${credential(projectId, id)}/restrict`, json("POST", input))); diff --git a/docs/operations/credential-security.md b/docs/operations/credential-security.md index 154a0383e1..206b059a8d 100644 --- a/docs/operations/credential-security.md +++ b/docs/operations/credential-security.md @@ -11,6 +11,12 @@ Code UX stores automation credentials through a broker rather than exposing secr The dashboard accepts secret values only on create, rotate, and replace requests. Responses contain configuration, scope, status, key-version, and validation metadata but never stored values. Access-event rows contain identifiers, binding keys, capabilities, outcomes, and denial reasons; they never contain secret material. +The Settings Integrations catalog exposes this broker as its first standard card. The card derives unavailable, ready/unconfigured, and configured states from backend health and project-visible metadata; **Manage** opens a project-aware detail view without rendering a secret, request body, or raw server error. Allowlisted non-owner projects can understand and use compatible global credentials but see management actions disabled. + +Create controls require deliberate capability selection and explicit project or global scope. Global allowlists retain the management owner, and scope-expanding creation or promotion is confirmed. Rename, test, rotation/replacement, restriction, promotion, and revocation report typed inline status, disable overlapping actions, and refresh after stale-version conflicts. Destructive and scope-expanding actions use keyboard-operable confirmation dialogs with focus restoration. + +All create, rotate, and replacement fields are controlled write-only inputs. They are never hydrated from metadata and are cleared after every submission outcome, project change, and component teardown. Credential metadata drafts and browser stores do not receive secret values. + Management inputs are validated at runtime rather than trusted from TypeScript types. Create requests must explicitly declare kind, scope, capabilities, and an allowlist (an empty array for project credentials). Names, kinds, binding keys, project ids, capabilities, and list counts are bounded; malformed arrays, unknown mutation fields, and control characters are rejected instead of being silently coerced. A stored value is limited to 64 KiB of UTF-8 data. Global allowlists must explicitly retain the management owner. Every lifecycle mutation carries `expectedVersion`. Successful name updates, validation tests, rotations/replacements, promotions, restrictions, and first-time revocations increment the version. A repeated revoke against an already-revoked credential at its current version is an idempotent no-op; stale requests return a conflict. Metadata updates may change only the bounded display name, so kind and management ownership remain immutable. diff --git a/docs/settings/integrations.md b/docs/settings/integrations.md index 937e3f17e4..08ebe62cc7 100644 --- a/docs/settings/integrations.md +++ b/docs/settings/integrations.md @@ -1,19 +1,19 @@ # Integrations -Lists provider, git-host, issue-tracker, and read-only importer integrations and exposes manage/add actions. +Lists automation credentials, providers, git hosts, issue trackers, and read-only importer integrations and exposes manage/add actions. > Settings area: `integrations` > Dashboard documentation route: `/docs/settings-integrations` ## What This Area Is For -Lists provider, git-host, issue-tracker, and read-only importer integrations and exposes manage/add actions. This page expands the short Settings-page help text into an operator reference for deciding when to change this area, what behavior the controls affect, and what to verify after saving. +Lists automation credentials, providers, git hosts, issue trackers, and read-only importer integrations and exposes manage/add actions. This page expands the short Settings-page help text into an operator reference for deciding when to change this area, what behavior the controls affect, and what to verify after saving. Use it when you are configuring a new project, auditing inherited settings, or debugging behavior that changed after a system, project, or sprint override was saved. ## Controls And Runtime Effect -Cards show connection state, auth hints, active/configured importer status, and management entry points; host hints can import detected local settings. +Cards show connection state, auth hints, active/configured importer status, and management entry points; host hints can import detected local settings. Automation Credentials is the first catalog entry and reports secure-storage unavailable, ready but unconfigured, or configured state for the selected project. Its **Manage** action uses the same detail and back-navigation behavior as every other integration. | Control Surface | Runtime Effect | Review Before Saving | | --- | --- | --- | @@ -21,9 +21,21 @@ Cards show connection state, auth hints, active/configured importer status, and | Inherited values | Values can flow from system defaults into project and sprint behavior. | Check the source badge before assuming a value is project-specific. | | Related runtime paths | The affected service reads the saved settings during planning, dispatch, dashboard rendering, or maintenance work. | Re-run the affected workflow after changing operational settings. | +## Automation Credential Management + +Credential management is project-aware even when Settings is displaying system scope. Select a project before opening the detail view so Code UX can list only metadata visible to that project and determine whether the project has management authority. + +The create form requires an explicit name, kind, project or global scope, capability selection, and—when global scope is selected—an allowlist that retains the managing project. No capability is granted implicitly. Global creation and project-to-global promotion require confirmation because they expand access. + +Each project-managed credential supports bounded rename, metadata-only validation test, value rotation, encrypted-state replacement, monotonic access restriction, confirmed promotion, and confirmed revocation. Every lifecycle request uses the metadata version shown by the service. If another session wins the compare-and-swap update, the detail view refreshes metadata and asks the operator to review and retry instead of overwriting the newer state. + +Secret inputs are write-only. Create, rotate, and replace fields are never populated from responses, are cleared after successful or failed submissions and project changes, and are removed with the detail view. Notices, metadata cards, browser storage, and reusable drafts contain no secret value. An allowlisted project that is not the management owner sees a **Use only** state and cannot invoke management actions. + +Unavailable key custody leaves non-secret metadata visible and disables secret-bearing changes and tests. Follow the inline custody guidance, restore secure storage, then use **Refresh**. See [Automation Credential Security](../operations/credential-security.md) for encryption, authority, recovery, and API behavior. + ## Recommended Configuration -Configure provider and importer credentials at system scope and use project overrides only for repository-specific git hosts or importer defaults. +Configure provider and importer credentials at system scope and use project overrides only for repository-specific git hosts or importer defaults. Automation credentials follow their own project-aware ownership and allowlist policy rather than Settings inheritance. For Google Drive, link an existing host-side sync or mount directory and enable the opt-in Docker mount only for projects that need it. The mount defaults to read-only; see [Google Drive Project Mount](./google-drive-mount.md) for access, inheritance, security, and troubleshooting details. This integration does not configure Google Drive API synchronization or credentials. @@ -55,6 +67,7 @@ If the saved setting does not appear to take effect: ## Related Documentation - [Settings overview](./index.md) +- [Automation Credential Security](../operations/credential-security.md) - [Google Drive Project Mount](./google-drive-mount.md) - [Dashboard Settings](../../dashboard/design-system-settings.md) - [Configuration and Storage](../configuration-and-storage.md) diff --git a/tests/dashboard/v2/settings-integrations-panel.test.tsx b/tests/dashboard/v2/settings-integrations-panel.test.tsx index 240f37eeab..c3e3c1136d 100644 --- a/tests/dashboard/v2/settings-integrations-panel.test.tsx +++ b/tests/dashboard/v2/settings-integrations-panel.test.tsx @@ -3,15 +3,27 @@ /** @jsx h */ /** @jsxFrag Fragment */ import { h, Fragment } from "preact"; +import { useState } from "preact/hooks"; import { describe, expect, it, vi, afterEach } from "vitest"; import { render, waitFor, screen, fireEvent, cleanup, within } from "@testing-library/preact"; +import userEvent from "@testing-library/user-event"; import { SettingsIntegrationsPanel } from "../../../dashboard/src/v2/components/settings/panels/SettingsIntegrationsPanel.js"; import { fetchLocalFiles } from "../../../dashboard/src/v2/lib/project-api.js"; +import { fetchAutomationCredentials, fetchCredentialHealth } from "../../../dashboard/src/v2/lib/automation-credential-api.js"; vi.mock("../../../dashboard/src/v2/lib/project-api.js", () => ({ fetchLocalFiles: vi.fn(), })); +vi.mock("../../../dashboard/src/v2/lib/automation-credential-api.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchAutomationCredentials: vi.fn(), + fetchCredentialHealth: vi.fn(), + }; +}); + vi.mock("gsap", () => { const applyStyles = (target: unknown, props: Record) => { if (!(target instanceof HTMLElement)) return; @@ -162,6 +174,66 @@ describe("SettingsIntegrationsPanel", () => { ...overrides, }); + it("places automation credentials first and supports keyboard Manage, back navigation, and focus restoration", async () => { + const user = userEvent.setup(); + vi.mocked(fetchAutomationCredentials).mockResolvedValue([]); + vi.mocked(fetchCredentialHealth).mockResolvedValue({ + available: true, + secure: true, + provider: "local-file", + keyId: "root", + keyVersion: 1, + }); + + const Harness = () => { + const [selectedIntegration, setSelectedIntegration] = useState<"automation-credentials" | "github" | null>(null); + return ( + + ); + }; + + const { container } = render(); + const card = await waitFor(() => container.querySelector('[data-integration-card="automation-credentials"]') as HTMLElement); + expect(container.textContent?.indexOf("Automation Credentials")).toBeLessThan(container.textContent?.indexOf("GitHub") ?? -1); + expect(within(card).getByText("Ready · not configured")).toBeTruthy(); + expect(screen.queryByText("Automation credential management")).toBeNull(); + + const manageButton = within(card).getByRole("button", { name: "Manage" }); + manageButton.focus(); + await user.keyboard("{Enter}"); + expect(await screen.findByText("Automation credential management")).toBeTruthy(); + const backButton = screen.getByRole("button", { name: "Back to Integrations" }); + await waitFor(() => expect(document.activeElement).toBe(backButton)); + + await user.keyboard("{Enter}"); + await waitFor(() => expect(screen.queryByText("Automation credential management")).toBeNull()); + await waitFor(() => expect(document.activeElement).toBe(manageButton)); + }); + it("keeps the selected integration detail in flow so long forms are not clipped", async () => { const state = { activeScope: "system", diff --git a/tests/dashboard/v2/settings-page-state.test.tsx b/tests/dashboard/v2/settings-page-state.test.tsx index 6ab0d65f2e..ccfc37886c 100644 --- a/tests/dashboard/v2/settings-page-state.test.tsx +++ b/tests/dashboard/v2/settings-page-state.test.tsx @@ -549,7 +549,7 @@ describe("useSettingsPageState", () => { result.current.setSettingsSearch("automation"); }); - expect(result.current.filteredCategories.length).toBe(1); + expect(result.current.filteredCategories.map((category) => category.id)).toEqual(["general", "integrations"]); expect(result.current.activeCategory).toBe("general"); }); From 9731123ff05f74976da7bc2cc84f97b0b4ad2488 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 22:38:35 +0000 Subject: [PATCH 10/22] fix(task T05): address qa review via codex --- .../__tests__/CustomDashboardViewer.test.tsx | 83 +++++++++++++++++++ .../src/v2/lib/custom-dashboard-runtime.ts | 75 +++++++++++++---- .../custom-dashboard-foundation.md | 4 +- ...chitecture-custom-dashboard-foundation.mdx | 4 +- docs-web/content/docs/developer-http-api.mdx | 2 +- .../docs/developer-management-actions.mdx | 2 +- docs-web/content/docs/developer-mcp-tools.mdx | 2 +- .../docs/user-dashboard-custom-dashboards.mdx | 4 +- docs-web/developer/http-api.md | 2 +- docs-web/developer/management-actions.md | 2 +- docs-web/developer/mcp-tools.md | 2 +- docs-web/user/dashboard/custom-dashboards.md | 4 +- .../custom-dashboard-foundation.md | 2 +- docs/dashboard/custom-dashboards.md | 4 +- docs/mcp/tools-and-contracts.md | 4 +- .../management/custom-dashboard-actions.ts | 32 ++++--- src/mcp/management/payload-parsers.ts | 18 +++- src/server/custom-dashboard-routes.ts | 14 ++-- src/server/route-utils.ts | 11 ++- ...om-dashboard-credential-binding-service.ts | 73 +++++++++++++++- ...anagement-custom-dashboard-actions.test.ts | 55 ++++++++++++ .../server/custom-dashboard-routes.test.ts | 40 ++++++++- 22 files changed, 383 insertions(+), 56 deletions(-) diff --git a/dashboard/src/v2/components/custom-dashboards/__tests__/CustomDashboardViewer.test.tsx b/dashboard/src/v2/components/custom-dashboards/__tests__/CustomDashboardViewer.test.tsx index a70a3812e8..5ff9a4202b 100644 --- a/dashboard/src/v2/components/custom-dashboards/__tests__/CustomDashboardViewer.test.tsx +++ b/dashboard/src/v2/components/custom-dashboards/__tests__/CustomDashboardViewer.test.tsx @@ -6,6 +6,7 @@ import "@testing-library/jest-dom/vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CustomDashboardViewer } from "../CustomDashboardViewer.js"; import type { CustomDashboardRecord, CustomDashboardRevisionRecord } from "../../../types.js"; +import { resolvePublishedCustomDashboardRuntime } from "../../../lib/custom-dashboard-runtime.js"; import { createDefaultCustomDashboardDraft } from "../../../lib/custom-dashboard-view-models.js"; vi.mock("../../../lib/motion/tokens.js", () => ({ @@ -189,6 +190,88 @@ describe("CustomDashboardViewer", () => { expect(iframe).not.toHaveAttribute("srcdoc", expect.stringContaining("not directly executable")); }); + it("recursively removes binding IDs from viewer records, sources, metadata, and artifacts", async () => { + const credentialId = "viewer-nested-binding-id-canary"; + const nestedDashboard: CustomDashboardRecord = { + ...dashboard, + manifest: { + ...dashboard.manifest, + metadata: { nested: { credentialId, value: `prefix-${credentialId}-suffix` } }, + }, + runtimeMetadata: { nested: { credentialId, value: credentialId } }, + credentialBindings: [{ slotId: "metrics_api", credentialId }], + }; + const nestedRevision: CustomDashboardRevisionRecord = { + ...revision, + manifest: nestedDashboard.manifest, + fileBundle: { + files: [{ + path: "index.html", + content: `
Nested dashboard
`, + contentType: "text/html", + }], + metadata: { nested: { credentialId, value: credentialId } }, + }, + sourceNodeGraph: { + nodes: [{ + id: "metrics", + type: "integrations_metadata", + title: "Metrics", + config: { nested: { credentialId, value: credentialId } }, + }], + edges: [], + metadata: { nested: { value: credentialId } }, + }, + styleguide: { [credentialId]: "binding-shaped-key", nested: { value: credentialId } }, + validationReport: { + valid: true, + summary: `Passed without ${credentialId}`, + issues: [], + metadata: { nested: { credentialId, value: credentialId } }, + }, + runtimeMetadata: { + validation: { + viewerArtifact: { + kind: "vite-dist", + entryFile: "index.html", + files: [{ + path: "index.html", + content: `
Nested artifact
`, + contentType: "text/html", + }], + }, + }, + nested: { credentialId, value: credentialId }, + }, + credentialBindings: [{ slotId: "metrics_api", credentialId }], + }; + + const resolution = resolvePublishedCustomDashboardRuntime(nestedDashboard, [nestedRevision]); + expect(resolution.status).toBe("ready"); + expect(JSON.stringify(resolution)).not.toContain(credentialId); + + render( + , + ); + const iframe = screen.getByTitle("Published custom dashboard: Delivery Pulse") as HTMLIFrameElement; + expect(iframe).not.toHaveAttribute("srcdoc", expect.stringContaining(credentialId)); + const postMessage = vi.spyOn(iframe.contentWindow!, "postMessage").mockImplementation(() => undefined); + + window.dispatchEvent(new MessageEvent("message", { + data: { type: "codeux-custom-dashboard:source-request", requestId: "request-nested", sourceId: "metrics" }, + source: iframe.contentWindow, + })); + + await waitFor(() => expect(postMessage).toHaveBeenCalled()); + expect(JSON.stringify(postMessage.mock.calls)).not.toContain(credentialId); + expect(JSON.stringify(postMessage.mock.calls)).not.toContain(STORED_CREDENTIAL_PLAINTEXT_CANARY); + }); + it("blocks drafts and shows the validation report with a validate/publish action", () => { render( ([ "integrations", "external_api", ]); +const CREDENTIAL_BINDING_ID_REDACTION = "[REDACTED_CREDENTIAL_BINDING_ID]"; +const CREDENTIAL_BINDING_PROPERTY_NAMES = new Set(["credentialbindings", "credentialid"]); export function resolvePublishedCustomDashboardRuntime( dashboard: CustomDashboardRecord, revisions: CustomDashboardRevisionRecord[], ): CustomDashboardRuntimeResolution { + const credentialIds = collectCredentialBindingIds(dashboard, revisions); + const safeDashboard = sanitizeViewerValue(dashboard, credentialIds); + const safeRevisions = sanitizeViewerValue(revisions, credentialIds); const publishedRevision = dashboard.publishedRevisionId ? revisions.find((revision) => revision.id === dashboard.publishedRevisionId) ?? null : null; - const safePublishedRevision = publishedRevision ? omitRevisionCredentialBindings(publishedRevision) : null; - const validationReport = getLastValidationReport(revisions); + const safePublishedRevision = publishedRevision ? sanitizeViewerValue(publishedRevision, credentialIds) : null; + const validationReport = getLastValidationReport(safeRevisions); - if (dashboard.status === "archived") { + if (safeDashboard.status === "archived") { return { status: "blocked", reason: "Archived custom dashboards cannot be opened.", validationReport, publishedRevision: safePublishedRevision }; } - if (dashboard.status !== "published") { + if (safeDashboard.status !== "published") { return { status: "blocked", reason: "Only published custom dashboards can be opened. Validate and publish a revision first.", @@ -92,7 +97,7 @@ export function resolvePublishedCustomDashboardRuntime( publishedRevision: safePublishedRevision, }; } - if (!dashboard.publishedRevisionId || !publishedRevision) { + if (!safeDashboard.publishedRevisionId || !publishedRevision) { return { status: "blocked", reason: "This custom dashboard has no published revision.", @@ -104,19 +109,19 @@ export function resolvePublishedCustomDashboardRuntime( return { status: "blocked", reason: "The published revision no longer has a passed validation report.", - validationReport: publishedRevision.validationReport ?? validationReport, + validationReport: safePublishedRevision?.validationReport ?? validationReport, publishedRevision: safePublishedRevision, }; } - const readyRevision = omitRevisionCredentialBindings(publishedRevision); + const readyRevision = sanitizeViewerValue(publishedRevision, credentialIds); return { status: "ready", runtime: { - dashboard: omitDashboardCredentialBindings(dashboard), + dashboard: safeDashboard, revision: readyRevision, - document: buildCustomDashboardFrameDocument(dashboard, readyRevision), + document: buildCustomDashboardFrameDocument(safeDashboard, readyRevision), }, }; } @@ -124,6 +129,17 @@ export function resolvePublishedCustomDashboardRuntime( export function buildCustomDashboardFrameDocument( dashboard: CustomDashboardRecord, revision: CustomDashboardRevisionRecord, +): string { + const credentialIds = collectCredentialBindingIds(dashboard, [revision]); + return buildSanitizedCustomDashboardFrameDocument( + sanitizeViewerValue(dashboard, credentialIds), + sanitizeViewerValue(revision, credentialIds), + ); +} + +function buildSanitizedCustomDashboardFrameDocument( + dashboard: CustomDashboardRecord, + revision: CustomDashboardRevisionRecord, ): string { const entryFile = revision.fileBundle.files.find((file) => file.path === revision.manifest.entryFile) ?? null; const bridgeConfig = { @@ -375,13 +391,44 @@ function buildBridgeBootstrapScript(config: Record): string { ].join("\n"); } -function omitDashboardCredentialBindings(dashboard: CustomDashboardRecord): CustomDashboardRecord { - const { credentialBindings: _credentialBindings, ...safe } = dashboard; - return safe; +function collectCredentialBindingIds( + dashboard: CustomDashboardRecord, + revisions: CustomDashboardRevisionRecord[], +): string[] { + const credentialIds = new Set(); + for (const record of [dashboard, ...revisions]) { + for (const binding of record.credentialBindings ?? []) { + if (binding.credentialId) credentialIds.add(binding.credentialId); + } + } + return [...credentialIds].sort((left, right) => right.length - left.length); +} + +function sanitizeViewerValue(value: T, credentialIds: readonly string[]): T { + return sanitizeViewerUnknown(value, credentialIds) as T; } -function omitRevisionCredentialBindings(revision: CustomDashboardRevisionRecord): CustomDashboardRevisionRecord { - const { credentialBindings: _credentialBindings, ...safe } = revision; +function sanitizeViewerUnknown(value: unknown, credentialIds: readonly string[]): unknown { + if (typeof value === "string") { + return credentialIds.reduce( + (safe, credentialId) => safe.split(credentialId).join(CREDENTIAL_BINDING_ID_REDACTION), + value, + ); + } + if (Array.isArray(value)) { + return value.map((entry) => sanitizeViewerUnknown(entry, credentialIds)); + } + if (!value || typeof value !== "object") return value; + + const safe: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const normalizedKey = key.toLowerCase(); + if (CREDENTIAL_BINDING_PROPERTY_NAMES.has(normalizedKey) + || credentialIds.some((credentialId) => key.includes(credentialId))) { + continue; + } + safe[key] = sanitizeViewerUnknown(entry, credentialIds); + } return safe; } diff --git a/docs-web/architecture/custom-dashboard-foundation.md b/docs-web/architecture/custom-dashboard-foundation.md index 69b20c78f8..6b309b8a82 100644 --- a/docs-web/architecture/custom-dashboard-foundation.md +++ b/docs-web/architecture/custom-dashboard-foundation.md @@ -59,7 +59,7 @@ Validation flow: - A validation session is marked `passed` only after install, build, start, and root URL health checks succeed. Build/start/health failures are recorded as failed validation reports with bounded log excerpts. - Runtime metadata persists the workspace path, log path, host port, container id/name, image, validation URL path, commands, latest error/log excerpt, and a browser-ready Vite `dist` artifact for passed revisions so the published viewer can render TSX-based drafts without a live validation container. -Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. REST and MCP publication re-run metadata-only binding review immediately before calling the repository, then require either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed binding review, queued/running/cancelled validation, missing state, and cross-revision sessions are rejected before the publication pointer changes. +Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. REST and MCP publication re-run metadata-only binding review immediately before calling the repository, then require either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed binding review returns sanitized slot-specific issues without credential IDs or values; queued/running/cancelled validation, missing state, and cross-revision sessions are rejected before the publication pointer changes. ## REST and MCP Surface @@ -95,7 +95,7 @@ Draft edits remain persisted bundle text sent back through API calls; generated Published dashboards open through `CustomDashboardViewer`, which resolves the active `publishedRevisionId` from the loaded dashboard detail and renders only when the dashboard status is `published`, the published revision exists, and that revision still has a valid passed validation report. Draft, rejected, archived, unvalidated, and missing-publication states render a local blocked panel with the last validation report and a return-to-editor action rather than executing the bundle. -The viewer uses a sandboxed iframe `srcdoc` document so generated dashboard code never runs inside the main Preact bundle. For validated TSX/Preact revisions, it prefers the persisted Vite `dist` viewer artifact from revision runtime metadata and inlines the artifact's HTML, CSS, and JavaScript into the frame document. Older direct HTML or browser-ready JavaScript entry files still render through the previous entry-file path. The frame receives a frozen `codeUxDataBridge` / `CodeUXCustomDashboard` object and can request only declared source nodes by `id` through `postMessage`. Parent and frame handlers verify the expected window source. Generic dashboard/viewer records and frame configuration omit credential-binding IDs; only the dedicated metadata-management responses expose them. The parent page handles source requests with explicit same-origin API calls for project execution data, project stats, and overview telemetry; integration metadata is limited to non-secret source-node metadata; external API nodes are placeholders and return clear unavailable-source errors. +The viewer uses a sandboxed iframe `srcdoc` document so generated dashboard code never runs inside the main Preact bundle. For validated TSX/Preact revisions, it prefers the persisted Vite `dist` viewer artifact from revision runtime metadata and inlines the artifact's HTML, CSS, and JavaScript into the frame document. Older direct HTML or browser-ready JavaScript entry files still render through the previous entry-file path. The frame receives a frozen `codeUxDataBridge` / `CodeUXCustomDashboard` object and can request only declared source nodes by `id` through `postMessage`. Parent and frame handlers verify the expected window source. Generic dashboard/viewer records and frame configuration recursively redact known binding IDs from nested dashboard, revision, source, runtime-metadata, file, validation-report, and viewer-artifact content; only the dedicated metadata-management responses expose IDs. The parent page handles source requests with explicit same-origin API calls for project execution data, project stats, and overview telemetry; integration metadata is limited to non-secret source-node metadata; external API nodes are placeholders and return clear unavailable-source errors. Navigation is centralized through `dashboard/src/v2/lib/navigation-items.ts`, so both the kinetic dock and sidebar expose the Dashboards destination with stable labels, tour markers, and route prefetching. diff --git a/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx b/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx index 69b20c78f8..6b309b8a82 100644 --- a/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx +++ b/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx @@ -59,7 +59,7 @@ Validation flow: - A validation session is marked `passed` only after install, build, start, and root URL health checks succeed. Build/start/health failures are recorded as failed validation reports with bounded log excerpts. - Runtime metadata persists the workspace path, log path, host port, container id/name, image, validation URL path, commands, latest error/log excerpt, and a browser-ready Vite `dist` artifact for passed revisions so the published viewer can render TSX-based drafts without a live validation container. -Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. REST and MCP publication re-run metadata-only binding review immediately before calling the repository, then require either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed binding review, queued/running/cancelled validation, missing state, and cross-revision sessions are rejected before the publication pointer changes. +Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. REST and MCP publication re-run metadata-only binding review immediately before calling the repository, then require either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed binding review returns sanitized slot-specific issues without credential IDs or values; queued/running/cancelled validation, missing state, and cross-revision sessions are rejected before the publication pointer changes. ## REST and MCP Surface @@ -95,7 +95,7 @@ Draft edits remain persisted bundle text sent back through API calls; generated Published dashboards open through `CustomDashboardViewer`, which resolves the active `publishedRevisionId` from the loaded dashboard detail and renders only when the dashboard status is `published`, the published revision exists, and that revision still has a valid passed validation report. Draft, rejected, archived, unvalidated, and missing-publication states render a local blocked panel with the last validation report and a return-to-editor action rather than executing the bundle. -The viewer uses a sandboxed iframe `srcdoc` document so generated dashboard code never runs inside the main Preact bundle. For validated TSX/Preact revisions, it prefers the persisted Vite `dist` viewer artifact from revision runtime metadata and inlines the artifact's HTML, CSS, and JavaScript into the frame document. Older direct HTML or browser-ready JavaScript entry files still render through the previous entry-file path. The frame receives a frozen `codeUxDataBridge` / `CodeUXCustomDashboard` object and can request only declared source nodes by `id` through `postMessage`. Parent and frame handlers verify the expected window source. Generic dashboard/viewer records and frame configuration omit credential-binding IDs; only the dedicated metadata-management responses expose them. The parent page handles source requests with explicit same-origin API calls for project execution data, project stats, and overview telemetry; integration metadata is limited to non-secret source-node metadata; external API nodes are placeholders and return clear unavailable-source errors. +The viewer uses a sandboxed iframe `srcdoc` document so generated dashboard code never runs inside the main Preact bundle. For validated TSX/Preact revisions, it prefers the persisted Vite `dist` viewer artifact from revision runtime metadata and inlines the artifact's HTML, CSS, and JavaScript into the frame document. Older direct HTML or browser-ready JavaScript entry files still render through the previous entry-file path. The frame receives a frozen `codeUxDataBridge` / `CodeUXCustomDashboard` object and can request only declared source nodes by `id` through `postMessage`. Parent and frame handlers verify the expected window source. Generic dashboard/viewer records and frame configuration recursively redact known binding IDs from nested dashboard, revision, source, runtime-metadata, file, validation-report, and viewer-artifact content; only the dedicated metadata-management responses expose IDs. The parent page handles source requests with explicit same-origin API calls for project execution data, project stats, and overview telemetry; integration metadata is limited to non-secret source-node metadata; external API nodes are placeholders and return clear unavailable-source errors. Navigation is centralized through `dashboard/src/v2/lib/navigation-items.ts`, so both the kinetic dock and sidebar expose the Dashboards destination with stable labels, tour markers, and route prefetching. diff --git a/docs-web/content/docs/developer-http-api.mdx b/docs-web/content/docs/developer-http-api.mdx index 0552b84230..de3e38ef37 100644 --- a/docs-web/content/docs/developer-http-api.mdx +++ b/docs-web/content/docs/developer-http-api.mdx @@ -285,7 +285,7 @@ Only instruction markdown is writable, and only compatibility-critical system ad | `DELETE` | `/api/custom-dashboard-validations/:sessionId` | Remove a validation session after cleanup. | | `ALL` | `/api/custom-dashboard-validations/:sessionId/proxy{*rest}` | Proxy same-origin traffic to the validation runtime host port. | -Authenticated remote access to binding routes requires the credential-administrator role, project access, and enabled remote credential management. Binding responses contain credential IDs and metadata only; generic dashboard responses omit them. Validation and publication fail closed for required or incompatible bindings without resolving secret values, and publication keeps the previously published revision unchanged on any denial. +Authenticated remote access to binding routes requires the credential-administrator role, project access, and enabled remote credential management. Binding responses contain credential IDs and metadata only; generic dashboard responses recursively redact known IDs from nested content. Validation and publication fail closed for required or incompatible bindings without resolving secret values, publication denials return sanitized slot-specific issues, and the previously published revision remains unchanged on any denial. --- diff --git a/docs-web/content/docs/developer-management-actions.mdx b/docs-web/content/docs/developer-management-actions.mdx index 17c4ec144e..df027ed55f 100644 --- a/docs-web/content/docs/developer-management-actions.mdx +++ b/docs-web/content/docs/developer-management-actions.mdx @@ -307,7 +307,7 @@ Project-scoped generated dashboards, immutable revisions, detached validation se | `bind_credential` | ✅ | `projectId`, `dashboardId`, `slotId`, `credentialId`, `expectedBindingRevision` | Bind or replace a slot by credential ID after the stateful approval handshake. | | `unbind_credential` | ✅ | `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision` | Remove a slot binding after the stateful approval handshake. | -Credential actions reject secret-bearing or undeclared fields and never resolve plaintext. Validation and publication review required and bound slots against backend health, project access, status, kind, and required capabilities; a denial blocks the operation before the active publication pointer changes. Generic custom-dashboard responses omit binding IDs. +Credential actions reject secret-bearing or undeclared fields and never resolve plaintext. Validation and publication review required and bound slots against backend health, project access, status, kind, and required capabilities; a denial blocks the operation before the active publication pointer changes and returns sanitized slot-specific issues. Generic custom-dashboard responses recursively redact known binding IDs from nested content. --- diff --git a/docs-web/content/docs/developer-mcp-tools.mdx b/docs-web/content/docs/developer-mcp-tools.mdx index 3683f6abf8..d8ab249cc0 100644 --- a/docs-web/content/docs/developer-mcp-tools.mdx +++ b/docs-web/content/docs/developer-mcp-tools.mdx @@ -140,7 +140,7 @@ For `manage_projects` setup, clients may send setup options either as `setup.opt For the full per-action payloads and return shapes, see [Management actions](/docs/developer-management-actions). -Custom-dashboard credential actions are project-scoped and metadata-only. `list_credential_slots` returns bounded compatible credential metadata; `bind_credential` and `unbind_credential` require `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision`, and the stateful approval handshake, with `credentialId` added for bind/replace. They reject secret-bearing or undeclared fields. Validation and publication fail closed on required or incompatible bindings without resolving plaintext, and generic custom-dashboard MCP responses omit binding IDs. +Custom-dashboard credential actions are project-scoped and metadata-only. `list_credential_slots` returns bounded compatible credential metadata; `bind_credential` and `unbind_credential` require `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision`, and the stateful approval handshake, with `credentialId` added for bind/replace. They reject secret-bearing or undeclared fields. Validation and publication fail closed on required or incompatible bindings without resolving plaintext; publication errors retain sanitized slot-specific issues, and generic custom-dashboard MCP responses recursively redact known binding IDs from nested content. ### Background sprint planning diff --git a/docs-web/content/docs/user-dashboard-custom-dashboards.mdx b/docs-web/content/docs/user-dashboard-custom-dashboards.mdx index 42fa2e5174..e8c530218b 100644 --- a/docs-web/content/docs/user-dashboard-custom-dashboards.mdx +++ b/docs-web/content/docs/user-dashboard-custom-dashboards.mdx @@ -51,8 +51,8 @@ The same workflow is available through the dashboard REST API: - `DELETE /api/custom-dashboard-validations/:sessionId` - `ALL /api/custom-dashboard-validations/:sessionId/proxy{*rest}` -Remote callers to credential-binding routes require the credential-administrator role, project access, and enabled remote credential management. Required missing bindings and bound credentials that are revoked, inaccessible, unconfigured, wrong-kind, missing capabilities, or blocked by unavailable key custody fail before workspace creation and are rechecked before publication. Optional unbound slots remain valid. +Remote callers to credential-binding routes require the credential-administrator role, project access, and enabled remote credential management. Required missing bindings and bound credentials that are revoked, inaccessible, unconfigured, wrong-kind, missing capabilities, or blocked by unavailable key custody fail before workspace creation and are rechecked before publication. REST and MCP publication denials include sanitized slot-specific issues without credential IDs or values. Optional unbound slots remain valid. -Custom-dashboard binding is metadata-only: no secret is resolved, and credential values and binding IDs are excluded from generated files, bridges, Docker configuration, validation output, generic REST/MCP responses, iframe configuration, and browser messages. Dedicated binding-management responses may return credential IDs and non-secret metadata so operators and agents can select them. +Custom-dashboard binding is metadata-only: no secret is resolved, and credential values and binding IDs are excluded from generated files, bridges, Docker configuration, validation output, generic REST/MCP responses, iframe configuration, and browser messages. Generic response and viewer boundaries recursively redact known IDs from nested manifests, files, source graphs, runtime metadata, validation reports, and viewer artifacts. Dedicated binding-management responses may return credential IDs and non-secret metadata so operators and agents can select them. Published dashboards render inside a sandboxed iframe. For TSX/Preact drafts such as the default `src/dashboard.tsx` bundle, the viewer uses the persisted validation artifact instead of the source entry file, so it can open after publication even when the detached validation preview is gone. The frame can request only declared source nodes through the Code UX bridge, parent and frame handlers verify the expected window source, and the parent dashboard returns data through same-origin API calls. diff --git a/docs-web/developer/http-api.md b/docs-web/developer/http-api.md index 3ccde70023..104df146a9 100644 --- a/docs-web/developer/http-api.md +++ b/docs-web/developer/http-api.md @@ -285,7 +285,7 @@ Only instruction markdown is writable, and only compatibility-critical system ad | `DELETE` | `/api/custom-dashboard-validations/:sessionId` | Remove a validation session after cleanup. | | `ALL` | `/api/custom-dashboard-validations/:sessionId/proxy{*rest}` | Proxy same-origin traffic to the validation runtime host port. | -Authenticated remote access to binding routes requires the credential-administrator role, project access, and enabled remote credential management. Binding responses contain credential IDs and metadata only; generic dashboard responses omit them. Validation and publication fail closed for required or incompatible bindings without resolving secret values, and publication keeps the previously published revision unchanged on any denial. +Authenticated remote access to binding routes requires the credential-administrator role, project access, and enabled remote credential management. Binding responses contain credential IDs and metadata only; generic dashboard responses recursively redact known IDs from nested content. Validation and publication fail closed for required or incompatible bindings without resolving secret values, publication denials return sanitized slot-specific issues, and the previously published revision remains unchanged on any denial. --- diff --git a/docs-web/developer/management-actions.md b/docs-web/developer/management-actions.md index 219a025129..91fdb9b599 100644 --- a/docs-web/developer/management-actions.md +++ b/docs-web/developer/management-actions.md @@ -307,7 +307,7 @@ Project-scoped generated dashboards, immutable revisions, detached validation se | `bind_credential` | ✅ | `projectId`, `dashboardId`, `slotId`, `credentialId`, `expectedBindingRevision` | Bind or replace a slot by credential ID after the stateful approval handshake. | | `unbind_credential` | ✅ | `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision` | Remove a slot binding after the stateful approval handshake. | -Credential actions reject secret-bearing or undeclared fields and never resolve plaintext. Validation and publication review required and bound slots against backend health, project access, status, kind, and required capabilities; a denial blocks the operation before the active publication pointer changes. Generic custom-dashboard responses omit binding IDs. +Credential actions reject secret-bearing or undeclared fields and never resolve plaintext. Validation and publication review required and bound slots against backend health, project access, status, kind, and required capabilities; a denial blocks the operation before the active publication pointer changes and returns sanitized slot-specific issues. Generic custom-dashboard responses recursively redact known binding IDs from nested content. --- diff --git a/docs-web/developer/mcp-tools.md b/docs-web/developer/mcp-tools.md index 3da0396f42..224d062bd5 100644 --- a/docs-web/developer/mcp-tools.md +++ b/docs-web/developer/mcp-tools.md @@ -140,7 +140,7 @@ For `manage_projects` setup, clients may send setup options either as `setup.opt For the full per-action payloads and return shapes, see [Management actions](./management-actions.md). -Custom-dashboard credential actions are project-scoped and metadata-only. `list_credential_slots` returns bounded compatible credential metadata; `bind_credential` and `unbind_credential` require `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision`, and the stateful approval handshake, with `credentialId` added for bind/replace. They reject secret-bearing or undeclared fields. Validation and publication fail closed on required or incompatible bindings without resolving plaintext, and generic custom-dashboard MCP responses omit binding IDs. +Custom-dashboard credential actions are project-scoped and metadata-only. `list_credential_slots` returns bounded compatible credential metadata; `bind_credential` and `unbind_credential` require `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision`, and the stateful approval handshake, with `credentialId` added for bind/replace. They reject secret-bearing or undeclared fields. Validation and publication fail closed on required or incompatible bindings without resolving plaintext; publication errors retain sanitized slot-specific issues, and generic custom-dashboard MCP responses recursively redact known binding IDs from nested content. ### Background sprint planning diff --git a/docs-web/user/dashboard/custom-dashboards.md b/docs-web/user/dashboard/custom-dashboards.md index 42fa2e5174..e8c530218b 100644 --- a/docs-web/user/dashboard/custom-dashboards.md +++ b/docs-web/user/dashboard/custom-dashboards.md @@ -51,8 +51,8 @@ The same workflow is available through the dashboard REST API: - `DELETE /api/custom-dashboard-validations/:sessionId` - `ALL /api/custom-dashboard-validations/:sessionId/proxy{*rest}` -Remote callers to credential-binding routes require the credential-administrator role, project access, and enabled remote credential management. Required missing bindings and bound credentials that are revoked, inaccessible, unconfigured, wrong-kind, missing capabilities, or blocked by unavailable key custody fail before workspace creation and are rechecked before publication. Optional unbound slots remain valid. +Remote callers to credential-binding routes require the credential-administrator role, project access, and enabled remote credential management. Required missing bindings and bound credentials that are revoked, inaccessible, unconfigured, wrong-kind, missing capabilities, or blocked by unavailable key custody fail before workspace creation and are rechecked before publication. REST and MCP publication denials include sanitized slot-specific issues without credential IDs or values. Optional unbound slots remain valid. -Custom-dashboard binding is metadata-only: no secret is resolved, and credential values and binding IDs are excluded from generated files, bridges, Docker configuration, validation output, generic REST/MCP responses, iframe configuration, and browser messages. Dedicated binding-management responses may return credential IDs and non-secret metadata so operators and agents can select them. +Custom-dashboard binding is metadata-only: no secret is resolved, and credential values and binding IDs are excluded from generated files, bridges, Docker configuration, validation output, generic REST/MCP responses, iframe configuration, and browser messages. Generic response and viewer boundaries recursively redact known IDs from nested manifests, files, source graphs, runtime metadata, validation reports, and viewer artifacts. Dedicated binding-management responses may return credential IDs and non-secret metadata so operators and agents can select them. Published dashboards render inside a sandboxed iframe. For TSX/Preact drafts such as the default `src/dashboard.tsx` bundle, the viewer uses the persisted validation artifact instead of the source entry file, so it can open after publication even when the detached validation preview is gone. The frame can request only declared source nodes through the Code UX bridge, parent and frame handlers verify the expected window source, and the parent dashboard returns data through same-origin API calls. diff --git a/docs/architecture/custom-dashboard-foundation.md b/docs/architecture/custom-dashboard-foundation.md index 69b20c78f8..effd80145a 100644 --- a/docs/architecture/custom-dashboard-foundation.md +++ b/docs/architecture/custom-dashboard-foundation.md @@ -95,7 +95,7 @@ Draft edits remain persisted bundle text sent back through API calls; generated Published dashboards open through `CustomDashboardViewer`, which resolves the active `publishedRevisionId` from the loaded dashboard detail and renders only when the dashboard status is `published`, the published revision exists, and that revision still has a valid passed validation report. Draft, rejected, archived, unvalidated, and missing-publication states render a local blocked panel with the last validation report and a return-to-editor action rather than executing the bundle. -The viewer uses a sandboxed iframe `srcdoc` document so generated dashboard code never runs inside the main Preact bundle. For validated TSX/Preact revisions, it prefers the persisted Vite `dist` viewer artifact from revision runtime metadata and inlines the artifact's HTML, CSS, and JavaScript into the frame document. Older direct HTML or browser-ready JavaScript entry files still render through the previous entry-file path. The frame receives a frozen `codeUxDataBridge` / `CodeUXCustomDashboard` object and can request only declared source nodes by `id` through `postMessage`. Parent and frame handlers verify the expected window source. Generic dashboard/viewer records and frame configuration omit credential-binding IDs; only the dedicated metadata-management responses expose them. The parent page handles source requests with explicit same-origin API calls for project execution data, project stats, and overview telemetry; integration metadata is limited to non-secret source-node metadata; external API nodes are placeholders and return clear unavailable-source errors. +The viewer uses a sandboxed iframe `srcdoc` document so generated dashboard code never runs inside the main Preact bundle. For validated TSX/Preact revisions, it prefers the persisted Vite `dist` viewer artifact from revision runtime metadata and inlines the artifact's HTML, CSS, and JavaScript into the frame document. Older direct HTML or browser-ready JavaScript entry files still render through the previous entry-file path. The frame receives a frozen `codeUxDataBridge` / `CodeUXCustomDashboard` object and can request only declared source nodes by `id` through `postMessage`. Parent and frame handlers verify the expected window source. Generic dashboard/viewer records and frame configuration recursively redact known binding IDs from nested dashboard, revision, source, runtime-metadata, file, validation-report, and viewer-artifact content; only the dedicated metadata-management responses expose IDs. The parent page handles source requests with explicit same-origin API calls for project execution data, project stats, and overview telemetry; integration metadata is limited to non-secret source-node metadata; external API nodes are placeholders and return clear unavailable-source errors. Navigation is centralized through `dashboard/src/v2/lib/navigation-items.ts`, so both the kinetic dock and sidebar expose the Dashboards destination with stable labels, tour markers, and route prefetching. diff --git a/docs/dashboard/custom-dashboards.md b/docs/dashboard/custom-dashboards.md index 7c34644c4d..857896aac5 100644 --- a/docs/dashboard/custom-dashboards.md +++ b/docs/dashboard/custom-dashboards.md @@ -88,7 +88,7 @@ Custom dashboard routes are registered with the dashboard server: | `ALL` | `/api/custom-dashboard-validations/:sessionId/proxy{*rest}` | Same-origin proxy to the detached validation runtime. | | `ALL` | `/api/custom-dashboards/validation-sessions/:sessionId/proxy{*rest}` | Backward-compatible validation proxy route. | -The binding routes are credential-management routes: authenticated remote callers require `credential_admin`, project access, and enabled remote credential management. Stale binding revisions return `409`; incompatible credential selection returns `403`. Publication first repeats metadata-only binding review, then applies the repository validation gate. Active publications remain the opening source of truth while later validation sessions run. +The binding routes are credential-management routes: authenticated remote callers require `credential_admin`, project access, and enabled remote credential management. Stale binding revisions return `409`; incompatible credential selection returns `403`. Publication first repeats metadata-only binding review, then applies the repository validation gate. REST and MCP denials preserve a sanitized `issues` array with slot-specific `field`, `code`, and `message` values while omitting credential IDs and values. Active publications remain the opening source of truth while later validation sessions run. ## MCP Surface @@ -122,7 +122,7 @@ During validation, Code UX: - health-checks the root URL before marking the session passed - records workspace path, log path, container id/name, host port, validation proxy path, commands, and log excerpts in runtime metadata -Required missing bindings and bound credentials that are missing, revoked, inaccessible, unconfigured, wrong-kind, missing capabilities, or blocked by unavailable/insecure key custody fail with slot-specific issues before workspace creation. Optional unbound slots remain valid. No custom-dashboard path resolves secret plaintext: credential values and binding IDs stay out of generated source, file bundles, bridge files, Docker arguments and mounts, validation reports and logs, viewer records, iframe configuration, and browser messages. Dedicated credential-binding management responses may return credential IDs and non-secret metadata so operators and agents can select them. +Required missing bindings and bound credentials that are missing, revoked, inaccessible, unconfigured, wrong-kind, missing capabilities, or blocked by unavailable/insecure key custody fail with slot-specific issues before workspace creation. Optional unbound slots remain valid. No custom-dashboard path resolves secret plaintext: credential values and binding IDs stay out of generated source, file bundles, bridge files, Docker arguments and mounts, validation reports and logs, viewer records, iframe configuration, and browser messages. Generic response and viewer boundaries recursively redact known binding IDs from nested manifests, file content and metadata, source graphs, styleguides, runtime metadata, validation reports, and persisted viewer artifacts. Dedicated credential-binding management responses may return credential IDs and non-secret metadata so operators and agents can select them. Stopping a validation session removes the detached container. It does not invalidate a passed revision report. Removing a validation session deletes the session row after cleanup; the revision's validation metadata remains the publication gate. diff --git a/docs/mcp/tools-and-contracts.md b/docs/mcp/tools-and-contracts.md index eb13e0b0dd..fe690a7f68 100644 --- a/docs/mcp/tools-and-contracts.md +++ b/docs/mcp/tools-and-contracts.md @@ -267,9 +267,9 @@ Payload fields: Validation sessions move through `queued`, `building`, `running`, `passed`, `failed`, or `cancelled`. `validate_revision` starts the detached Docker validation runtime; it does not publish the revision. A passed session means install, build, detached preview startup, and root health checks completed successfully. -`validate_revision` and `publish_revision` perform a fresh metadata-only credential compatibility review. Required unbound slots and missing, revoked, inaccessible, unconfigured, wrong-kind, insufficient-capability, or unavailable-backend bindings fail closed with slot-specific validation issues. Optional unbound slots remain valid. Only after that review does `publish_revision` apply the repository validation-state gate, so the prior published revision remains active on any denial. +`validate_revision` and `publish_revision` perform a fresh metadata-only credential compatibility review. Required unbound slots and missing, revoked, inaccessible, unconfigured, wrong-kind, insufficient-capability, or unavailable-backend bindings fail closed with slot-specific validation issues. Optional unbound slots remain valid. A rejected publication returns the sanitized slot-specific `issues` array in the error result without credential IDs or values. Only after that review does `publish_revision` apply the repository validation-state gate, so the prior published revision remains active on any denial. -Generic custom-dashboard MCP responses omit binding IDs. Only `list_credential_slots`, `bind_credential`, and `unbind_credential` may return binding IDs and non-secret credential metadata. These actions never accept or return plaintext and never call credential secret resolution. +Generic custom-dashboard MCP responses recursively redact known binding IDs from dashboard, revision, source, runtime-metadata, validation-report, file, and viewer-artifact content. Only `list_credential_slots`, `bind_credential`, and `unbind_credential` may return binding IDs and non-secret credential metadata. These actions never accept or return plaintext and never call credential secret resolution. The generated dashboard data-source graph is user-declared JSON with `nodes`, `edges`, and optional `metadata`. Runtime viewer source types currently map to Code UX project execution data, project stats, overview telemetry, non-secret integration metadata, and unavailable `external_api` placeholders. Do not claim arbitrary external API connectors are available through this surface until a dedicated sanitized proxy contract exists. diff --git a/src/mcp/management/custom-dashboard-actions.ts b/src/mcp/management/custom-dashboard-actions.ts index bdcfac1f3c..5dfa88adb3 100644 --- a/src/mcp/management/custom-dashboard-actions.ts +++ b/src/mcp/management/custom-dashboard-actions.ts @@ -11,6 +11,8 @@ import type { import type { CustomDashboardRepository } from "../../repositories/custom-dashboard-repository.js"; import type { CustomDashboardValidationService } from "../../services/custom-dashboard-validation-service.js"; import { + collectCustomDashboardCredentialBindingIds, + CustomDashboardCredentialBindingValidationError, type CustomDashboardCredentialBindingService, withoutCustomDashboardCredentialBindings, withoutCustomDashboardRevisionCredentialBindings, @@ -71,7 +73,7 @@ export class CustomDashboardActions { return { result: { dashboards: this.customDashboardRepository.listDashboardsByProject(projectId) - .map(withoutCustomDashboardCredentialBindings), + .map((dashboard) => withoutCustomDashboardCredentialBindings(dashboard)), }, }; } @@ -82,11 +84,13 @@ export class CustomDashboardActions { if (!dashboard) { throw managementValidationError(`Custom dashboard not found: ${dashboardId}`, "dashboardId"); } + const revisions = this.customDashboardRepository.listRevisions(dashboard.id); + const credentialIds = collectCustomDashboardCredentialBindingIds([dashboard, ...revisions]); return { result: { - dashboard: withoutCustomDashboardCredentialBindings(dashboard), - revisions: this.customDashboardRepository.listRevisions(dashboard.id) - .map(withoutCustomDashboardRevisionCredentialBindings), + dashboard: withoutCustomDashboardCredentialBindings(dashboard, credentialIds), + revisions: revisions.map((revision) => + withoutCustomDashboardRevisionCredentialBindings(revision, credentialIds)), }, }; } @@ -154,11 +158,18 @@ export class CustomDashboardActions { if (!dashboardRecord) { throw managementValidationError(`Custom dashboard not found: ${dashboardId}`, "dashboardId"); } - await this.customDashboardCredentialBindingService.requireValidRevision( - dashboardRecord.projectId, - dashboardId, - revisionId, - ); + try { + await this.customDashboardCredentialBindingService.requireValidRevision( + dashboardRecord.projectId, + dashboardId, + revisionId, + ); + } catch (error) { + if (error instanceof CustomDashboardCredentialBindingValidationError) { + throw managementValidationError(error.message, undefined, error.issues); + } + throw error; + } const dashboard = this.customDashboardRepository.publishRevision( dashboardId, revisionId, @@ -186,7 +197,8 @@ export class CustomDashboardActions { private dataCatalog(payload: Record): ManagementResponseEnvelope { const projectId = parseRequiredString(payload, "projectId"); - const dashboards = this.customDashboardRepository.listDashboardsByProject(projectId); + const dashboards = this.customDashboardRepository.listDashboardsByProject(projectId) + .map((dashboard) => withoutCustomDashboardCredentialBindings(dashboard)); const sources: Array = dashboards.flatMap((dashboard) => dashboard.sourceNodeGraph.nodes.map((node) => ({ ...node, diff --git a/src/mcp/management/payload-parsers.ts b/src/mcp/management/payload-parsers.ts index a49202a000..123ea8ae11 100644 --- a/src/mcp/management/payload-parsers.ts +++ b/src/mcp/management/payload-parsers.ts @@ -1,11 +1,16 @@ import type { ManagementResponseEnvelope } from "../../contracts/internal-management-types.js"; +import type { CustomDashboardValidationIssue } from "../../contracts/custom-dashboard-types.js"; export type ManagementErrorKind = "validation" | "runtime"; export class ManagementValidationError extends Error { readonly kind = "validation" as const; - constructor(message: string, readonly field?: string) { + constructor( + message: string, + readonly field?: string, + readonly issues?: CustomDashboardValidationIssue[], + ) { super(message); this.name = "ManagementValidationError"; } @@ -15,8 +20,12 @@ export function isManagementValidationError(error: unknown): error is Management return error instanceof ManagementValidationError; } -export function managementValidationError(message: string, field?: string): ManagementValidationError { - return new ManagementValidationError(message, field); +export function managementValidationError( + message: string, + field?: string, + issues?: CustomDashboardValidationIssue[], +): ManagementValidationError { + return new ManagementValidationError(message, field, issues); } export function formatManagementErrorEnvelope( @@ -36,6 +45,9 @@ export function formatManagementErrorEnvelope( if (isManagementValidationError(error) && error.field) { result.field = error.field; } + if (isManagementValidationError(error) && error.issues) { + result.issues = error.issues; + } return { result }; } diff --git a/src/server/custom-dashboard-routes.ts b/src/server/custom-dashboard-routes.ts index 3c44650a34..748257fbaf 100644 --- a/src/server/custom-dashboard-routes.ts +++ b/src/server/custom-dashboard-routes.ts @@ -9,6 +9,7 @@ import type { DashboardDependencies } from "./dashboard-server.js"; import { asyncRoute } from "./route-utils.js"; import { requireTrimmedString } from "./request-parsers.js"; import { + collectCustomDashboardCredentialBindingIds, withoutCustomDashboardCredentialBindings, withoutCustomDashboardRevisionCredentialBindings, } from "../services/custom-dashboard-credential-binding-service.js"; @@ -19,7 +20,7 @@ export function registerCustomDashboardRoutes(app: Express, deps: DashboardDepen const projectId = requireTrimmedString(req.params.projectId, "projectId"); res.json({ dashboards: repository.listDashboardsByProject(projectId) - .map(withoutCustomDashboardCredentialBindings), + .map((dashboard) => withoutCustomDashboardCredentialBindings(dashboard)), }); })); @@ -33,7 +34,8 @@ export function registerCustomDashboardRoutes(app: Express, deps: DashboardDepen app.get("/api/projects/:projectId/custom-dashboards/data-catalog", asyncRoute(async (req, res) => { const repository = requireCustomDashboardRepository(deps); const projectId = requireTrimmedString(req.params.projectId, "projectId"); - const dashboards = repository.listDashboardsByProject(projectId); + const dashboards = repository.listDashboardsByProject(projectId) + .map((dashboard) => withoutCustomDashboardCredentialBindings(dashboard)); res.json({ projectId, dashboards: dashboards.map((dashboard) => ({ @@ -60,10 +62,12 @@ export function registerCustomDashboardRoutes(app: Express, deps: DashboardDepen if (!dashboard) { throw new HttpRouteError(404, `Custom dashboard not found: ${dashboardId}`); } + const revisions = repository.listRevisions(dashboard.id); + const credentialIds = collectCustomDashboardCredentialBindingIds([dashboard, ...revisions]); res.json({ - dashboard: withoutCustomDashboardCredentialBindings(dashboard), - revisions: repository.listRevisions(dashboard.id) - .map(withoutCustomDashboardRevisionCredentialBindings), + dashboard: withoutCustomDashboardCredentialBindings(dashboard, credentialIds), + revisions: revisions.map((revision) => + withoutCustomDashboardRevisionCredentialBindings(revision, credentialIds)), }); })); diff --git a/src/server/route-utils.ts b/src/server/route-utils.ts index 5e01222d60..894bfc9e2b 100644 --- a/src/server/route-utils.ts +++ b/src/server/route-utils.ts @@ -1,17 +1,26 @@ import { toHttpRouteError } from "./http-errors.js"; import type { Request, Response, RequestHandler } from "express"; -export function toErrorResponse(error: unknown, prefix?: string): { error: string; details?: unknown[] } { +export function toErrorResponse( + error: unknown, + prefix?: string, +): { error: string; details?: unknown[]; issues?: unknown[] } { const message = error instanceof Error ? error.message : String(error); const details = error && typeof error === "object" && "details" in error ? (error as { details?: unknown }).details : undefined; + const issues = error && typeof error === "object" && "issues" in error + ? (error as { issues?: unknown }).issues + : undefined; const response = prefix ? { error: `${prefix}: ${message}` } : { error: message }; if (Array.isArray(details)) { return { ...response, details }; } + if (Array.isArray(issues)) { + return { ...response, issues }; + } if (prefix) { return response; } diff --git a/src/services/custom-dashboard-credential-binding-service.ts b/src/services/custom-dashboard-credential-binding-service.ts index d58509cb88..e6c5bb7170 100644 --- a/src/services/custom-dashboard-credential-binding-service.ts +++ b/src/services/custom-dashboard-credential-binding-service.ts @@ -27,6 +27,8 @@ import { const MAX_IDENTIFIER_LENGTH = 256; const MAX_CREDENTIAL_CANDIDATES = 100; const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/; +const CREDENTIAL_BINDING_ID_REDACTION = "[REDACTED_CREDENTIAL_BINDING_ID]"; +const CREDENTIAL_BINDING_PROPERTY_NAMES = new Set(["credentialbindings", "credentialid"]); export interface CustomDashboardCredentialCandidate { credentialId: string; @@ -59,9 +61,14 @@ export interface CustomDashboardCredentialBindingReview { } export class CustomDashboardCredentialBindingValidationError extends ValidationError { + readonly issues: CustomDashboardValidationIssue[]; + constructor(readonly review: CustomDashboardCredentialBindingReview) { - super(review.issues[0]?.message ?? "Custom dashboard credential bindings are invalid."); + const credentialIds = review.slots.flatMap((slot) => slot.binding ? [slot.binding.credentialId] : []); + const issues = sanitizeCustomDashboardCredentialBindingIds(review.issues, credentialIds); + super(issues[0]?.message ?? "Custom dashboard credential bindings are invalid."); this.name = "CustomDashboardCredentialBindingValidationError"; + this.issues = issues; } } @@ -380,15 +387,75 @@ export class CustomDashboardCredentialBindingService { export function withoutCustomDashboardCredentialBindings( dashboard: CustomDashboardRecord, + additionalCredentialIds: Iterable = [], ): Omit { - const { credentialBindings: _credentialBindings, ...safe } = dashboard; + const credentialIds = customDashboardCredentialBindingIds(dashboard, additionalCredentialIds); + const sanitized = sanitizeCustomDashboardCredentialBindingIds(dashboard, credentialIds); + const { credentialBindings: _credentialBindings, ...safe } = sanitized; return safe; } export function withoutCustomDashboardRevisionCredentialBindings( revision: CustomDashboardRevisionRecord, + additionalCredentialIds: Iterable = [], ): Omit { - const { credentialBindings: _credentialBindings, ...safe } = revision; + const credentialIds = customDashboardCredentialBindingIds(revision, additionalCredentialIds); + const sanitized = sanitizeCustomDashboardCredentialBindingIds(revision, credentialIds); + const { credentialBindings: _credentialBindings, ...safe } = sanitized; + return safe; +} + +export function collectCustomDashboardCredentialBindingIds( + records: Iterable, +): string[] { + const credentialIds = new Set(); + for (const record of records) { + for (const binding of record.credentialBindings ?? []) { + if (binding.credentialId) credentialIds.add(binding.credentialId); + } + } + return [...credentialIds]; +} + +export function sanitizeCustomDashboardCredentialBindingIds( + value: T, + credentialIds: Iterable, +): T { + const identifiers = [...new Set(credentialIds)] + .filter((credentialId) => credentialId.length > 0) + .sort((left, right) => right.length - left.length); + return sanitizeCredentialBindingValue(value, identifiers) as T; +} + +function customDashboardCredentialBindingIds( + record: CustomDashboardRecord | CustomDashboardRevisionRecord, + additionalCredentialIds: Iterable, +): string[] { + return collectCustomDashboardCredentialBindingIds([record]) + .concat([...additionalCredentialIds]); +} + +function sanitizeCredentialBindingValue(value: unknown, credentialIds: readonly string[]): unknown { + if (typeof value === "string") { + return credentialIds.reduce( + (safe, credentialId) => safe.split(credentialId).join(CREDENTIAL_BINDING_ID_REDACTION), + value, + ); + } + if (Array.isArray(value)) { + return value.map((entry) => sanitizeCredentialBindingValue(entry, credentialIds)); + } + if (!value || typeof value !== "object") return value; + + const safe: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const normalizedKey = key.toLowerCase(); + if (CREDENTIAL_BINDING_PROPERTY_NAMES.has(normalizedKey) + || credentialIds.some((credentialId) => key.includes(credentialId))) { + continue; + } + safe[key] = sanitizeCredentialBindingValue(entry, credentialIds); + } return safe; } diff --git a/tests/backend/mcp/management-custom-dashboard-actions.test.ts b/tests/backend/mcp/management-custom-dashboard-actions.test.ts index 2d461201ad..c9465d67cd 100644 --- a/tests/backend/mcp/management-custom-dashboard-actions.test.ts +++ b/tests/backend/mcp/management-custom-dashboard-actions.test.ts @@ -327,9 +327,64 @@ describe("manage_custom_dashboards", () => { expect(bound.result.bindings).toMatchObject({ valid: true, credentialBindingRevision: 2 }); expect(JSON.stringify(bound)).not.toContain(canary); + const boundDashboard = repository.getDashboardById(dashboard.id)!; + repository.updateDraft(dashboard.id, { + manifest: { + ...boundDashboard.manifest, + metadata: { nested: { credentialId: credential.id, value: `prefix-${credential.id}-suffix` } }, + }, + fileBundle: fileBundle(`export const nested = ${JSON.stringify(credential.id)};`), + sourceNodeGraph: { + nodes: [{ + id: "metrics", + type: "integrations_metadata", + title: "Metrics", + config: { nested: { credentialId: credential.id, value: credential.id } }, + }], + edges: [], + }, + runtimeMetadata: { + validation: { + viewerArtifact: { + kind: "vite-dist", + entryFile: "index.html", + files: [{ + path: "index.html", + content: `
Nested
`, + contentType: "text/html", + }], + }, + }, + }, + }); + const revision = repository.createRevision(dashboard.id); + const validation = repository.createValidationSession(revision.id, { + status: "passed", + validationReport: passedReport(), + finishedAt: new Date().toISOString(), + }); + const generic = parseResponse(await handler.handleManageCustomDashboards({ action: "get", dashboardId: dashboard.id })); expect(JSON.stringify(generic)).not.toContain("credentialBindings"); expect(JSON.stringify(generic)).not.toContain(credential.id); + const catalog = parseResponse(await handler.handleManageCustomDashboards({ action: "data_catalog", projectId })); + expect(JSON.stringify(catalog)).not.toContain(credential.id); + + credentialBroker.revoke(projectId, credential.id, { expectedVersion: credential.version }); + const deniedPublication = parseResponse(await handler.handleManageCustomDashboards({ + action: "publish_revision", + dashboardId: dashboard.id, + revisionId: revision.id, + validationSessionId: validation.id, + })); + expect(deniedPublication.result).toMatchObject({ + status: "error", + errorType: "validation", + issues: [expect.objectContaining({ field: "credentialBindings.metrics_api", code: "not_active" })], + }); + expect(JSON.stringify(deniedPublication)).not.toContain(credential.id); + expect(JSON.stringify(deniedPublication)).not.toContain(canary); + expect(repository.getDashboardById(dashboard.id)?.publishedRevisionId).toBeNull(); const unbindArgs = { action: "unbind_credential" as const, diff --git a/tests/backend/server/custom-dashboard-routes.test.ts b/tests/backend/server/custom-dashboard-routes.test.ts index 7f97f4ec70..e1fe2348f7 100644 --- a/tests/backend/server/custom-dashboard-routes.test.ts +++ b/tests/backend/server/custom-dashboard-routes.test.ts @@ -322,6 +322,38 @@ describe("custom dashboard routes", () => { expect(replaced.body).toMatchObject({ valid: true, credentialBindingRevision: 3 }); expect(replaced.body.slots[0].binding.credentialId).toBe(second.id); + const boundDashboard = repository.getDashboardById(dashboard.id)!; + repository.updateDraft(dashboard.id, { + manifest: { + ...boundDashboard.manifest, + metadata: { nested: { credentialId: second.id, value: `prefix-${second.id}-suffix` } }, + }, + fileBundle: fileBundle(`export const nested = ${JSON.stringify(second.id)};`), + sourceNodeGraph: { + nodes: [{ + id: "metrics", + type: "integrations_metadata", + title: "Metrics", + config: { nested: { credentialId: second.id, value: second.id } }, + }], + edges: [], + }, + styleguide: { nested: { value: second.id } }, + runtimeMetadata: { + validation: { + viewerArtifact: { + kind: "vite-dist", + entryFile: "index.html", + files: [{ + path: "index.html", + content: `
Nested
`, + contentType: "text/html", + }], + }, + }, + }, + }); + const stale = await request(app).put(route).send({ slotId: "metrics_api", credentialId: first.id, @@ -338,11 +370,13 @@ describe("custom dashboard routes", () => { expect(rejectedSecretField.status).toBe(400); expect(JSON.stringify(rejectedSecretField.body)).not.toContain(canary); + const revision = repository.createRevision(dashboard.id); const generic = await request(app).get(`/api/custom-dashboards/${dashboard.id}`); expect(JSON.stringify(generic.body)).not.toContain("credentialBindings"); expect(JSON.stringify(generic.body)).not.toContain(second.id); + const catalog = await request(app).get(`/api/projects/${projectId}/custom-dashboards/data-catalog`); + expect(JSON.stringify(catalog.body)).not.toContain(second.id); - const revision = repository.createRevision(dashboard.id); const validation = repository.createValidationSession(revision.id, { status: "passed", validationReport: passedReport(), @@ -354,7 +388,11 @@ describe("custom dashboard routes", () => { .send({ validationSessionId: validation.id }); expect(deniedPublication.status).toBe(400); expect(deniedPublication.body.error).toContain("not active"); + expect(deniedPublication.body.issues).toEqual([ + expect.objectContaining({ field: "credentialBindings.metrics_api", code: "not_active" }), + ]); expect(JSON.stringify(deniedPublication.body)).not.toContain(second.id); + expect(JSON.stringify(deniedPublication.body)).not.toContain(canary); expect(repository.getDashboardById(dashboard.id)?.publishedRevisionId).toBeNull(); const unbound = await request(app) From 1d841a5b5a651bfec793f4af92a9cbb6c17da477 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 22:49:53 +0000 Subject: [PATCH 11/22] fix(task T06): address qa review via codex --- .../settings/AutomationCredentialManager.tsx | 14 ++++++++++--- .../AutomationCredentialManager.test.tsx | 20 ++++++++++++++++--- .../src/v2/components/ui/ConfirmDialog.tsx | 20 +++++++++++++++---- docs/settings/integrations.md | 2 +- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/dashboard/src/v2/components/settings/AutomationCredentialManager.tsx b/dashboard/src/v2/components/settings/AutomationCredentialManager.tsx index caf67fc5b7..3fc6393711 100644 --- a/dashboard/src/v2/components/settings/AutomationCredentialManager.tsx +++ b/dashboard/src/v2/components/settings/AutomationCredentialManager.tsx @@ -164,6 +164,7 @@ export const AutomationCredentialManager: FunctionComponent>({}); const [restrictionProjects, setRestrictionProjects] = useState>({}); const [promotionProjects, setPromotionProjects] = useState>({}); + const [confirmDialogKey, setConfirmDialogKey] = useState(0); const secretContainerRef = useRef(null); const confirm = useConfirmDialog(); @@ -229,6 +230,13 @@ export const AutomationCredentialManager: FunctionComponent ({ ...current, [credentialId]: next })); }; + const requestConfirmation = (options: Parameters[0]): Promise => { + // A lifecycle mutation can finish before the shared dialog's exit animation unmounts. + // Remount for every request so a following action cannot inherit closing or typed state. + setConfirmDialogKey((current) => current + 1); + return confirm.requestConfirm(options); + }; + const runMutation = async ( credential: AutomationCredentialMetadata, action: string, @@ -274,7 +282,7 @@ export const AutomationCredentialManager: FunctionComponent - +
); }; diff --git a/dashboard/src/v2/components/settings/__tests__/AutomationCredentialManager.test.tsx b/dashboard/src/v2/components/settings/__tests__/AutomationCredentialManager.test.tsx index 9bec124201..0a383ccebd 100644 --- a/dashboard/src/v2/components/settings/__tests__/AutomationCredentialManager.test.tsx +++ b/dashboard/src/v2/components/settings/__tests__/AutomationCredentialManager.test.tsx @@ -223,11 +223,25 @@ describe("AutomationCredentialManager", () => { })); await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); - await user.click(within(item).getByRole("button", { name: "Revoke" })); + const revokeTrigger = within(item).getByRole("button", { name: "Revoke" }); + revokeTrigger.focus(); + await user.click(revokeTrigger); expect(revokeAutomationCredential).not.toHaveBeenCalled(); - await user.type(screen.getByLabelText("Type REVOKE to confirm"), "REVOKE"); - await user.click(screen.getByRole("button", { name: "Revoke credential" })); + const revokeDialog = await screen.findByRole("dialog", { name: "Revoke Deployment token?" }); + const confirmationInput = within(revokeDialog).getByLabelText("Type REVOKE to confirm"); + const guardedRevokeButton = within(revokeDialog).getByRole("button", { name: "Type REVOKE to enable Revoke credential" }); + expect((guardedRevokeButton as HTMLButtonElement).disabled).toBe(true); + + await user.type(confirmationInput, "REVOK"); + expect((guardedRevokeButton as HTMLButtonElement).disabled).toBe(true); + expect(revokeAutomationCredential).not.toHaveBeenCalled(); + await user.type(confirmationInput, "E"); + await user.click(within(revokeDialog).getByRole("button", { name: "Revoke credential" })); await waitFor(() => expect(revokeAutomationCredential).toHaveBeenCalledWith("project-1", "credential-1", { expectedVersion: 3 })); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(screen.queryByLabelText("Type REVOKE to confirm")).toBeNull(); + expect(await screen.findByText("Credential revoked.")).toBeTruthy(); + await waitFor(() => expect(item.contains(document.activeElement)).toBe(true)); }); it("prevents management from an allowlisted non-owner project", async () => { diff --git a/dashboard/src/v2/components/ui/ConfirmDialog.tsx b/dashboard/src/v2/components/ui/ConfirmDialog.tsx index f58fa4d0fa..81127005fa 100644 --- a/dashboard/src/v2/components/ui/ConfirmDialog.tsx +++ b/dashboard/src/v2/components/ui/ConfirmDialog.tsx @@ -266,6 +266,8 @@ export function ConfirmDialog({ isOpen, options, onConfirm, onCancel, restoreFoc const [confirmationText, setConfirmationText] = useState(""); const cardRef = useRef(null); + const wasOpenRef = useRef(isOpen); + const animationGenerationRef = useRef(0); const trapRef = useFocusTrap(shouldRender && !isClosing, { onClose: () => handleClose(onCancel), restoreFocus }); const reducedMotion = useReducedMotion(); const gsapTokens = useGsapInteractionTokens(); @@ -276,10 +278,15 @@ export function ConfirmDialog({ isOpen, options, onConfirm, onCancel, restoreFoc }; useEffect(() => { + const isOpening = isOpen && !wasOpenRef.current; + wasOpenRef.current = isOpen; if (isOpen) { setShouldRender(true); setIsClosing(false); - setConfirmationText(""); + if (isOpening) { + setConfirmationText(""); + setConfirmFlash(false); + } } else if (shouldRender) { setIsClosing(true); } @@ -288,11 +295,12 @@ export function ConfirmDialog({ isOpen, options, onConfirm, onCancel, restoreFoc useLayoutEffect(() => { if (shouldRender && !isClosing) { const d_card = reducedMotion ? 0 : gsapTokens.enterExit.duration; + animationGenerationRef.current += 1; if (cardRef.current) { gsap.fromTo(cardRef.current, { y: reducedMotion ? 0 : MODAL_MOTION.entry.yStart, opacity: MODAL_MOTION.entry.opacityStart, scale: reducedMotion ? 1 : MODAL_MOTION.entry.scaleStart, filter: reducedMotion ? MODAL_MOTION.entry.filterEnd : MODAL_MOTION.entry.filterStart }, - { y: MODAL_MOTION.entry.yEnd, opacity: MODAL_MOTION.entry.opacityEnd, scale: MODAL_MOTION.entry.scaleEnd, filter: MODAL_MOTION.entry.filterEnd, duration: d_card, ease: gsapTokens.enterExit.ease } + { y: MODAL_MOTION.entry.yEnd, opacity: MODAL_MOTION.entry.opacityEnd, scale: MODAL_MOTION.entry.scaleEnd, filter: MODAL_MOTION.entry.filterEnd, duration: d_card, ease: gsapTokens.enterExit.ease, overwrite: true } ); } } @@ -316,20 +324,24 @@ export function ConfirmDialog({ isOpen, options, onConfirm, onCancel, restoreFoc useEffect(() => { if (isClosing) { const d = reducedMotion ? 0 : gsapTokens.enterExit.duration; + const card = cardRef.current; + const animationGeneration = ++animationGenerationRef.current; const onExitComplete = () => { + if (animationGeneration !== animationGenerationRef.current) return; setShouldRender(false); setIsClosing(false); }; - if (cardRef.current) { - gsap.to(cardRef.current, { + if (card) { + gsap.to(card, { y: MODAL_MOTION.exit.yEnd, opacity: MODAL_MOTION.exit.opacityEnd, scale: MODAL_MOTION.exit.scaleEnd, filter: MODAL_MOTION.exit.filterEnd, duration: d, ease: gsapTokens.enterExit.ease, + overwrite: true, onComplete: onExitComplete }); } else { diff --git a/docs/settings/integrations.md b/docs/settings/integrations.md index 08ebe62cc7..c19a83155a 100644 --- a/docs/settings/integrations.md +++ b/docs/settings/integrations.md @@ -27,7 +27,7 @@ Credential management is project-aware even when Settings is displaying system s The create form requires an explicit name, kind, project or global scope, capability selection, and—when global scope is selected—an allowlist that retains the managing project. No capability is granted implicitly. Global creation and project-to-global promotion require confirmation because they expand access. -Each project-managed credential supports bounded rename, metadata-only validation test, value rotation, encrypted-state replacement, monotonic access restriction, confirmed promotion, and confirmed revocation. Every lifecycle request uses the metadata version shown by the service. If another session wins the compare-and-swap update, the detail view refreshes metadata and asks the operator to review and retry instead of overwriting the newer state. +Each project-managed credential supports bounded rename, metadata-only validation test, value rotation, encrypted-state replacement, monotonic access restriction, confirmed promotion, and confirmed revocation. Revocation requires typing `REVOKE` exactly; each lifecycle confirmation starts with cleared confirmation state and returns focus to the credential controls when it closes. Every lifecycle request uses the metadata version shown by the service. If another session wins the compare-and-swap update, the detail view refreshes metadata and asks the operator to review and retry instead of overwriting the newer state. Secret inputs are write-only. Create, rotate, and replace fields are never populated from responses, are cleared after successful or failed submissions and project changes, and are removed with the detail view. Notices, metadata cards, browser storage, and reusable drafts contain no secret value. An allowlisted project that is not the management owner sees a **Use only** state and cannot invoke management actions. From 69ad1687f38ff4ca6bc3d5cb463f6cc782f55f5f Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 22:51:55 +0000 Subject: [PATCH 12/22] fix(task T05): address qa review via codex --- .../docs/developer-management-actions.mdx | 2 +- docs-web/content/docs/developer-mcp-tools.mdx | 2 +- .../docs/user-dashboard-custom-dashboards.mdx | 2 +- docs-web/developer/management-actions.md | 2 +- docs-web/developer/mcp-tools.md | 2 +- docs-web/user/dashboard/custom-dashboards.md | 2 +- docs/dashboard/custom-dashboards.md | 2 +- docs/mcp/tools-and-contracts.md | 2 +- src/mcp/management-tool-handler.ts | 16 +++- .../management/custom-dashboard-actions.ts | 90 +++++++++++++++++-- ...anagement-custom-dashboard-actions.test.ts | 69 +++++++++++++- 11 files changed, 170 insertions(+), 21 deletions(-) diff --git a/docs-web/content/docs/developer-management-actions.mdx b/docs-web/content/docs/developer-management-actions.mdx index df027ed55f..c724bf31ae 100644 --- a/docs-web/content/docs/developer-management-actions.mdx +++ b/docs-web/content/docs/developer-management-actions.mdx @@ -307,7 +307,7 @@ Project-scoped generated dashboards, immutable revisions, detached validation se | `bind_credential` | ✅ | `projectId`, `dashboardId`, `slotId`, `credentialId`, `expectedBindingRevision` | Bind or replace a slot by credential ID after the stateful approval handshake. | | `unbind_credential` | ✅ | `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision` | Remove a slot binding after the stateful approval handshake. | -Credential actions reject secret-bearing or undeclared fields and never resolve plaintext. Validation and publication review required and bound slots against backend health, project access, status, kind, and required capabilities; a denial blocks the operation before the active publication pointer changes and returns sanitized slot-specific issues. Generic custom-dashboard responses recursively redact known binding IDs from nested content. +Credential actions reject secret-bearing, malformed approval, or undeclared fields before approval fingerprinting, reduce accepted mutations to their allowed metadata, and never resolve plaintext. Validation and publication review required and bound slots against backend health, project access, status, kind, and required capabilities; a denial blocks the operation before the active publication pointer changes and returns sanitized slot-specific issues. Generic custom-dashboard responses recursively redact known binding IDs from nested content. --- diff --git a/docs-web/content/docs/developer-mcp-tools.mdx b/docs-web/content/docs/developer-mcp-tools.mdx index d8ab249cc0..ee66a893c5 100644 --- a/docs-web/content/docs/developer-mcp-tools.mdx +++ b/docs-web/content/docs/developer-mcp-tools.mdx @@ -140,7 +140,7 @@ For `manage_projects` setup, clients may send setup options either as `setup.opt For the full per-action payloads and return shapes, see [Management actions](/docs/developer-management-actions). -Custom-dashboard credential actions are project-scoped and metadata-only. `list_credential_slots` returns bounded compatible credential metadata; `bind_credential` and `unbind_credential` require `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision`, and the stateful approval handshake, with `credentialId` added for bind/replace. They reject secret-bearing or undeclared fields. Validation and publication fail closed on required or incompatible bindings without resolving plaintext; publication errors retain sanitized slot-specific issues, and generic custom-dashboard MCP responses recursively redact known binding IDs from nested content. +Custom-dashboard credential actions are project-scoped and metadata-only. `list_credential_slots` returns bounded compatible credential metadata; `bind_credential` and `unbind_credential` require `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision`, and the stateful approval handshake, with `credentialId` added for bind/replace. Before fingerprinting, their arguments are strictly validated and rebuilt from only those allowed metadata fields, so secret-bearing, malformed approval, or undeclared fields cannot enter pending approval state. Validation and publication fail closed on required or incompatible bindings without resolving plaintext; publication errors retain sanitized slot-specific issues, and generic custom-dashboard MCP responses recursively redact known binding IDs from nested content. ### Background sprint planning diff --git a/docs-web/content/docs/user-dashboard-custom-dashboards.mdx b/docs-web/content/docs/user-dashboard-custom-dashboards.mdx index e8c530218b..327846bc5b 100644 --- a/docs-web/content/docs/user-dashboard-custom-dashboards.mdx +++ b/docs-web/content/docs/user-dashboard-custom-dashboards.mdx @@ -32,7 +32,7 @@ Generated dashboards should handle unavailable-source errors visibly. External A ## Agent and API Notes -Project Manager agents use the `manage_custom_dashboards` MCP tool to create drafts, list credential slots, bind or unbind credential IDs, create revisions, validate revisions, inspect logs, publish passed revisions, archive dashboards, and read the data catalog. Credential mutations require the normal stateful human-approval handshake and an optimistic `expectedBindingRevision`. +Project Manager agents use the `manage_custom_dashboards` MCP tool to create drafts, list credential slots, bind or unbind credential IDs, create revisions, validate revisions, inspect logs, publish passed revisions, archive dashboards, and read the data catalog. Credential mutations require the normal stateful human-approval handshake and an optimistic `expectedBindingRevision`; unsupported or secret-bearing fields are rejected before approval state is created. The same workflow is available through the dashboard REST API: diff --git a/docs-web/developer/management-actions.md b/docs-web/developer/management-actions.md index 91fdb9b599..7304e9f097 100644 --- a/docs-web/developer/management-actions.md +++ b/docs-web/developer/management-actions.md @@ -307,7 +307,7 @@ Project-scoped generated dashboards, immutable revisions, detached validation se | `bind_credential` | ✅ | `projectId`, `dashboardId`, `slotId`, `credentialId`, `expectedBindingRevision` | Bind or replace a slot by credential ID after the stateful approval handshake. | | `unbind_credential` | ✅ | `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision` | Remove a slot binding after the stateful approval handshake. | -Credential actions reject secret-bearing or undeclared fields and never resolve plaintext. Validation and publication review required and bound slots against backend health, project access, status, kind, and required capabilities; a denial blocks the operation before the active publication pointer changes and returns sanitized slot-specific issues. Generic custom-dashboard responses recursively redact known binding IDs from nested content. +Credential actions reject secret-bearing, malformed approval, or undeclared fields before approval fingerprinting, reduce accepted mutations to their allowed metadata, and never resolve plaintext. Validation and publication review required and bound slots against backend health, project access, status, kind, and required capabilities; a denial blocks the operation before the active publication pointer changes and returns sanitized slot-specific issues. Generic custom-dashboard responses recursively redact known binding IDs from nested content. --- diff --git a/docs-web/developer/mcp-tools.md b/docs-web/developer/mcp-tools.md index 224d062bd5..baa6f2c0f8 100644 --- a/docs-web/developer/mcp-tools.md +++ b/docs-web/developer/mcp-tools.md @@ -140,7 +140,7 @@ For `manage_projects` setup, clients may send setup options either as `setup.opt For the full per-action payloads and return shapes, see [Management actions](./management-actions.md). -Custom-dashboard credential actions are project-scoped and metadata-only. `list_credential_slots` returns bounded compatible credential metadata; `bind_credential` and `unbind_credential` require `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision`, and the stateful approval handshake, with `credentialId` added for bind/replace. They reject secret-bearing or undeclared fields. Validation and publication fail closed on required or incompatible bindings without resolving plaintext; publication errors retain sanitized slot-specific issues, and generic custom-dashboard MCP responses recursively redact known binding IDs from nested content. +Custom-dashboard credential actions are project-scoped and metadata-only. `list_credential_slots` returns bounded compatible credential metadata; `bind_credential` and `unbind_credential` require `projectId`, `dashboardId`, `slotId`, `expectedBindingRevision`, and the stateful approval handshake, with `credentialId` added for bind/replace. Before fingerprinting, their arguments are strictly validated and rebuilt from only those allowed metadata fields, so secret-bearing, malformed approval, or undeclared fields cannot enter pending approval state. Validation and publication fail closed on required or incompatible bindings without resolving plaintext; publication errors retain sanitized slot-specific issues, and generic custom-dashboard MCP responses recursively redact known binding IDs from nested content. ### Background sprint planning diff --git a/docs-web/user/dashboard/custom-dashboards.md b/docs-web/user/dashboard/custom-dashboards.md index e8c530218b..327846bc5b 100644 --- a/docs-web/user/dashboard/custom-dashboards.md +++ b/docs-web/user/dashboard/custom-dashboards.md @@ -32,7 +32,7 @@ Generated dashboards should handle unavailable-source errors visibly. External A ## Agent and API Notes -Project Manager agents use the `manage_custom_dashboards` MCP tool to create drafts, list credential slots, bind or unbind credential IDs, create revisions, validate revisions, inspect logs, publish passed revisions, archive dashboards, and read the data catalog. Credential mutations require the normal stateful human-approval handshake and an optimistic `expectedBindingRevision`. +Project Manager agents use the `manage_custom_dashboards` MCP tool to create drafts, list credential slots, bind or unbind credential IDs, create revisions, validate revisions, inspect logs, publish passed revisions, archive dashboards, and read the data catalog. Credential mutations require the normal stateful human-approval handshake and an optimistic `expectedBindingRevision`; unsupported or secret-bearing fields are rejected before approval state is created. The same workflow is available through the dashboard REST API: diff --git a/docs/dashboard/custom-dashboards.md b/docs/dashboard/custom-dashboards.md index 857896aac5..84a5633dc1 100644 --- a/docs/dashboard/custom-dashboards.md +++ b/docs/dashboard/custom-dashboards.md @@ -102,7 +102,7 @@ The dedicated MCP tool is `manage_custom_dashboards` and is available to the pro - `data_catalog` - `list_credential_slots`, `bind_credential`, `unbind_credential` -Credential actions use `projectId`, `dashboardId`, `slotId`, `credentialId`, and `expectedBindingRevision`; an optional `revisionId` reviews an immutable snapshot. Bind and unbind require the normal stateful human-approval handshake. They reject secret, header, environment, and other undeclared fields. Other important fields include `sessionId`, `validationSessionId`, `title`, `description`, `manifest`, `fileBundle`, `sourceNodeGraph`, `styleguide`, `runtimeMetadata`, `tail`, and `approval`. +Credential actions use `projectId`, `dashboardId`, `slotId`, `credentialId`, and `expectedBindingRevision`; an optional `revisionId` reviews an immutable snapshot. Bind and unbind require the normal stateful human-approval handshake. Before creating any approval fingerprint, Code UX rejects secret, header, environment, malformed approval, and other undeclared fields, then rebuilds the approval payload from only the allowed metadata. Other important fields include `sessionId`, `validationSessionId`, `title`, `description`, `manifest`, `fileBundle`, `sourceNodeGraph`, `styleguide`, `runtimeMetadata`, `tail`, and `approval`. The dashboard chat JSON-action bridge also understands the legacy `custom_dashboards` management domain, but agents should prefer the dedicated MCP tool when it is available. diff --git a/docs/mcp/tools-and-contracts.md b/docs/mcp/tools-and-contracts.md index fe690a7f68..40b9db7fa9 100644 --- a/docs/mcp/tools-and-contracts.md +++ b/docs/mcp/tools-and-contracts.md @@ -252,7 +252,7 @@ The restricted tool intentionally does not expose due-entry execution, arbitrary - `archive` clears any active publication and marks the dashboard archived. It follows the normal destructive-action approval fingerprint flow. - `data_catalog` returns project dashboard summaries and declared source nodes for agents building or inspecting generated dashboards. - `list_credential_slots` returns a bounded metadata-only review of declared slots, current bindings, backend health, and compatible credential candidates for the owning project. An optional `revisionId` reviews an immutable revision. -- `bind_credential` binds or replaces one declared slot by credential ID with `expectedBindingRevision`; `unbind_credential` removes one slot binding with the same optimistic guard. Both mutations require the stateful human-confirmation handshake. +- `bind_credential` binds or replaces one declared slot by credential ID with `expectedBindingRevision`; `unbind_credential` removes one slot binding with the same optimistic guard. Both mutations require the stateful human-confirmation handshake. Their arguments are strictly validated and reduced to the allowed metadata fields before an approval fingerprint is built, so secret-bearing or unsupported fields cannot enter pending approval state. Payload fields: diff --git a/src/mcp/management-tool-handler.ts b/src/mcp/management-tool-handler.ts index 918de984b1..0494c9e1b8 100644 --- a/src/mcp/management-tool-handler.ts +++ b/src/mcp/management-tool-handler.ts @@ -62,7 +62,10 @@ import { initializeProject } from "../domain/projects/project-initializer.js"; import { prepareGitProjectCreateInput } from "../services/project-git-clone-service.js"; import { PreviewActions } from "./management/preview-actions.js"; -import { CustomDashboardActions } from "./management/custom-dashboard-actions.js"; +import { + CustomDashboardActions, + normalizeCustomDashboardCredentialMutationArgs, +} from "./management/custom-dashboard-actions.js"; import { handleTelemetryActions } from "./management/telemetry-actions.js"; import { handleProjectAction } from "./management/project-actions.js"; import { SprintActions } from "./management/sprint-actions.js"; @@ -546,8 +549,15 @@ export class ManagementToolHandler { async handleManageCustomDashboards(args: ManageCustomDashboardsArgs): Promise<{ content: Array<{ type: string; text: string }> }> { try { - const managementArgs = { domain: "custom_dashboards", action: args.action, payload: args as unknown as Record, approval: args.approval }; - const dispatch = (approval = args.approval) => this.customDashboardActions.handleCustomDashboardAction({ ...managementArgs, approval }); + const normalizedArgs = normalizeCustomDashboardCredentialMutationArgs(args); + const managementArgs = { + domain: "custom_dashboards", + action: normalizedArgs.action, + payload: normalizedArgs as unknown as Record, + approval: normalizedArgs.approval, + }; + const dispatch = (approval = normalizedArgs.approval) => + this.customDashboardActions.handleCustomDashboardAction({ ...managementArgs, approval }); const approvalGate = await this.requireStatefulApproval(managementArgs, () => dispatch({ confirmed: false })); const envelope = approvalGate ?? this.recordStatefulApprovalRequirement(managementArgs, await dispatch()); return { content: [{ type: "text", text: JSON.stringify(envelope, null, 2) }] }; diff --git a/src/mcp/management/custom-dashboard-actions.ts b/src/mcp/management/custom-dashboard-actions.ts index 5dfa88adb3..f3416c4714 100644 --- a/src/mcp/management/custom-dashboard-actions.ts +++ b/src/mcp/management/custom-dashboard-actions.ts @@ -6,6 +6,7 @@ import type { } from "../../contracts/custom-dashboard-types.js"; import type { ManageCodeUxArgs, + ManageCustomDashboardsArgs, ManagementResponseEnvelope, } from "../../contracts/internal-management-types.js"; import type { CustomDashboardRepository } from "../../repositories/custom-dashboard-repository.js"; @@ -25,6 +26,84 @@ import { parseRequiredString, } from "./payload-parsers.js"; +const BIND_CREDENTIAL_ARGUMENT_KEYS = new Set([ + "action", + "projectId", + "dashboardId", + "slotId", + "credentialId", + "expectedBindingRevision", + "approval", +]); +const UNBIND_CREDENTIAL_ARGUMENT_KEYS = new Set([ + "action", + "projectId", + "dashboardId", + "slotId", + "expectedBindingRevision", + "approval", +]); + +export function normalizeCustomDashboardCredentialMutationArgs( + args: ManageCustomDashboardsArgs, +): ManageCustomDashboardsArgs { + if (args.action !== "bind_credential" && args.action !== "unbind_credential") return args; + + const payload = args as unknown as Record; + const allowedKeys = args.action === "bind_credential" + ? BIND_CREDENTIAL_ARGUMENT_KEYS + : UNBIND_CREDENTIAL_ARGUMENT_KEYS; + if (Object.keys(payload).some((key) => !allowedKeys.has(key))) { + throw managementValidationError( + `${args.action} contains unsupported or secret-bearing fields`, + "payload", + ); + } + + const expectedBindingRevision = payload.expectedBindingRevision; + if (typeof expectedBindingRevision !== "number" + || !Number.isSafeInteger(expectedBindingRevision) + || expectedBindingRevision < 1) { + throw managementValidationError( + "expectedBindingRevision must be a positive safe integer", + "expectedBindingRevision", + ); + } + + const approval = normalizeCredentialMutationApproval(payload.approval, args.action); + const normalized: ManageCustomDashboardsArgs = { + action: args.action, + projectId: parseRequiredString(payload, "projectId"), + dashboardId: parseRequiredString(payload, "dashboardId"), + slotId: parseRequiredString(payload, "slotId"), + expectedBindingRevision, + ...(approval ? { approval } : {}), + }; + if (args.action === "bind_credential") { + normalized.credentialId = parseRequiredString(payload, "credentialId"); + } + return normalized; +} + +function normalizeCredentialMutationApproval( + value: unknown, + action: "bind_credential" | "unbind_credential", +): ManageCustomDashboardsArgs["approval"] { + if (value === undefined) return undefined; + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw managementValidationError(`${action} approval must be an object`, "approval"); + } + const approval = value as Record; + if (Object.keys(approval).some((key) => key !== "confirmed") + || typeof approval.confirmed !== "boolean") { + throw managementValidationError( + `${action} approval accepts only a boolean confirmed field`, + "approval", + ); + } + return { confirmed: approval.confirmed }; +} + export class CustomDashboardActions { constructor( private readonly customDashboardRepository: CustomDashboardRepository, @@ -282,12 +361,11 @@ export class CustomDashboardActions { } function bindingMutationInput(payload: Record, unbind: boolean): Record { - const envelopeKeys = new Set(["action", "approval", "projectId", "dashboardId"]); - const input = Object.fromEntries(Object.entries(payload).filter(([key]) => !envelopeKeys.has(key))); - if (unbind && "credentialId" in input) { - throw managementValidationError("unbind_credential does not accept credentialId or secret-bearing fields", "credentialId"); - } - return input; + return { + slotId: payload.slotId, + expectedBindingRevision: payload.expectedBindingRevision, + ...(unbind ? {} : { credentialId: payload.credentialId }), + }; } function parseDashboardDraftPayload( diff --git a/tests/backend/mcp/management-custom-dashboard-actions.test.ts b/tests/backend/mcp/management-custom-dashboard-actions.test.ts index c9465d67cd..54f6fc509a 100644 --- a/tests/backend/mcp/management-custom-dashboard-actions.test.ts +++ b/tests/backend/mcp/management-custom-dashboard-actions.test.ts @@ -127,6 +127,12 @@ function parseResponse(response: { content: Array<{ text: string }> }): Record; } +function pendingApprovalFingerprints(handler: ManagementToolHandler): string[] { + return [...(handler as unknown as { + pendingDestructiveApprovals: Map; + }).pendingDestructiveApprovals.keys()]; +} + afterEach(async () => { await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); @@ -427,13 +433,68 @@ describe("manage_custom_dashboards", () => { expectedBindingRevision: 1, value: canary, }; - await handler.handleManageCustomDashboards(unsafe as any); - const denied = parseResponse(await handler.handleManageCustomDashboards({ + const deniedInitial = parseResponse(await handler.handleManageCustomDashboards(unsafe as any)); + expect(deniedInitial.result).toMatchObject({ status: "error", errorType: "validation", field: "payload" }); + expect(JSON.stringify(deniedInitial)).not.toContain(canary); + expect(pendingApprovalFingerprints(handler)).toEqual([]); + + const deniedConfirmed = parseResponse(await handler.handleManageCustomDashboards({ ...unsafe, approval: { confirmed: true }, } as any)); - expect(denied.result.status).toBe("error"); - expect(JSON.stringify(denied)).not.toContain(canary); + expect(deniedConfirmed.result.status).toBe("error"); + expect(JSON.stringify(deniedConfirmed)).not.toContain(canary); + expect(pendingApprovalFingerprints(handler)).toEqual([]); + + const deniedApprovalPayload = parseResponse(await handler.handleManageCustomDashboards({ + action: "bind_credential", + projectId, + dashboardId: dashboard.id, + slotId: "metrics_api", + credentialId: credential.id, + expectedBindingRevision: 1, + approval: { confirmed: false, value: canary }, + } as any)); + expect(deniedApprovalPayload.result).toMatchObject({ + status: "error", + errorType: "validation", + field: "approval", + }); + expect(JSON.stringify(deniedApprovalPayload)).not.toContain(canary); + expect(pendingApprovalFingerprints(handler)).toEqual([]); + + const cleanBind = { + action: "bind_credential" as const, + projectId, + dashboardId: dashboard.id, + slotId: "metrics_api", + credentialId: credential.id, + expectedBindingRevision: 1, + approval: { confirmed: true }, + }; + const cleanPreflight = parseResponse(await handler.handleManageCustomDashboards(cleanBind)); + expect(cleanPreflight.approvalRequired).toBe(true); + const fingerprints = pendingApprovalFingerprints(handler); + expect(fingerprints).toHaveLength(1); + expect(fingerprints[0]).not.toContain(canary); + expect(fingerprints[0]).not.toContain('"value"'); + + const bound = parseResponse(await handler.handleManageCustomDashboards(cleanBind)); + expect(bound.result.bindings.valid).toBe(true); + expect(pendingApprovalFingerprints(handler)).toEqual([]); + + const unsafeUnbind = { + action: "unbind_credential" as const, + projectId, + dashboardId: dashboard.id, + slotId: "metrics_api", + expectedBindingRevision: 2, + headers: { authorization: canary }, + }; + const deniedUnbind = parseResponse(await handler.handleManageCustomDashboards(unsafeUnbind as any)); + expect(deniedUnbind.result).toMatchObject({ status: "error", errorType: "validation", field: "payload" }); + expect(JSON.stringify(deniedUnbind)).not.toContain(canary); + expect(pendingApprovalFingerprints(handler)).toEqual([]); const crossProject = parseResponse(await handler.handleManageCustomDashboards({ action: "list_credential_slots", From 57c25a75bc4c6518bec1000e7fedce40d748448d Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 23:03:04 +0000 Subject: [PATCH 13/22] feat(task T07): implement via codex --- dashboard/src/v2/NodesPage.tsx | 123 ++++++++- .../components/nodes/NodeCredentialPicker.tsx | 239 ++++++++++++++++++ .../v2/components/nodes/NodeFlowInspector.tsx | 73 +++++- dashboard/src/v2/lib/node-flow-api.ts | 43 +++- .../docs/user-dashboard-node-flows.mdx | 4 +- docs-web/user/dashboard/node-flows.md | 4 +- docs/dashboard/node-flows.md | 4 +- tests/dashboard/v2/nodes-inspector.test.tsx | 128 +++++++++- tests/dashboard/v2/nodes-page.test.tsx | 182 ++++++++++++- 9 files changed, 764 insertions(+), 36 deletions(-) create mode 100644 dashboard/src/v2/components/nodes/NodeCredentialPicker.tsx diff --git a/dashboard/src/v2/NodesPage.tsx b/dashboard/src/v2/NodesPage.tsx index 850a24b10b..4fa60c9e7a 100644 --- a/dashboard/src/v2/NodesPage.tsx +++ b/dashboard/src/v2/NodesPage.tsx @@ -7,7 +7,8 @@ import { EmptyState } from "./components/ui/EmptyState.js"; import { Button } from "./components/ui/Button.js"; import { NodeFlowLibrary } from "./components/nodes/NodeFlowLibrary.js"; import { NodeFlowCanvas } from "./components/nodes/NodeFlowCanvas.js"; -import { NodeFlowInspector } from "./components/nodes/NodeFlowInspector.js"; +import { NodeFlowInspector, type CredentialBindingFeedback } from "./components/nodes/NodeFlowInspector.js"; +import type { CredentialSelectionResult } from "./components/nodes/NodeCredentialPicker.js"; import { NodePalette } from "./components/nodes/NodePalette.js"; import { NodeGovernancePanel } from "./components/nodes/NodeGovernancePanel.js"; import { NodeRunDebugger } from "./components/nodes/NodeRunDebugger.js"; @@ -19,9 +20,9 @@ import { fetchAgentPresets } from "./lib/agent-preset-api.js"; import { attachNodeFlowToAgent, cancelNodeFlowRun, compareNodeFlowVersions, createNodeFlowDraft, decideNodeFlowApproval, deleteNodeFlow, detachNodeFlowFromAgent, dryRunNodeFlowDraft, fetchNodeDefinition, fetchNodeFlow, fetchNodeFlowApprovals, fetchNodeFlowAttempts, fetchNodeFlowCatalog, fetchNodeFlowNodeRuns, - fetchNodeFlowAgentSkills, fetchNodeFlowRuns, fetchNodeFlows, patchNodeFlowDraft, publishNodeFlowDraft, requestNodeFlowCredential, + fetchNodeFlowAgentSkills, fetchNodeFlowRuns, fetchNodeFlows, patchNodeFlowDraft, publishNodeFlowDraft, retryNodeFlowRun, rollbackNodeFlow, runNodeFlow, validateNodeFlowDraft, - type NodeDefinitionSummary, type NodeFlowDryRunResponse, type NodeFlowVersionDiff, + NodeFlowDraftSaveError, type NodeDefinitionSummary, type NodeFlowDryRunResponse, type NodeFlowVersionDiff, } from "./lib/node-flow-api.js"; export const NODES_CANVAS_STORAGE_KEY = "codeux:nodes-canvas:v1"; @@ -34,7 +35,9 @@ export const NodesPage: FunctionComponent = () => { const projectRef = useRef(projectId); projectRef.current = projectId; const flowRef = useRef(null); const selectedFlowRef = useRef(null); + const selectedNodeRef = useRef(null); const reviewRequestRef = useRef(0); + const credentialMutationRequestRef = useRef(0); const mountedRef = useRef(true); const [flows, setFlows] = useState([]); const [catalog, setCatalog] = useState([]); @@ -67,6 +70,7 @@ export const NodesPage: FunctionComponent = () => { const [error, setError] = useState(null); const [migrationWarning, setMigrationWarning] = useState(null); const [notice, setNotice] = useState(null); + const [credentialFeedback, setCredentialFeedback] = useState(null); const selectedNode = useMemo(() => graph.nodes.find((node) => node.id === selectedNodeId) ?? null, [graph.nodes, selectedNodeId]); const selectedDefinition = selectedNode?.definition ? definitions[`${selectedNode.definition.type}@${selectedNode.definition.version}`] ?? null : null; @@ -75,13 +79,13 @@ export const NodesPage: FunctionComponent = () => { useEffect(() => { mountedRef.current = true; - return () => { mountedRef.current = false; reviewRequestRef.current += 1; }; + return () => { mountedRef.current = false; reviewRequestRef.current += 1; credentialMutationRequestRef.current += 1; }; }, []); const applyRecord = useCallback((flow: NodeFlowRecord): void => { flowRef.current = flow.id; selectedFlowRef.current = flow.id; setRecord(flow); setSelectedFlowId(flow.id); setTitle(flow.title); setDescription(flow.description); setGraph(flow.graph); - setSelectedNodeId(flow.graph.nodes[0]?.id ?? null); setReview(null); setDryRun(null); setDiff(null); + selectedNodeRef.current = flow.graph.nodes[0]?.id ?? null; setSelectedNodeId(selectedNodeRef.current); setReview(null); setDryRun(null); setDiff(null); }, []); const loadReview = useCallback(async (nextProjectId: string, flowId: string, signal?: AbortSignal): Promise => { @@ -130,8 +134,9 @@ export const NodesPage: FunctionComponent = () => { }, [applyRecord, loadReview]); useEffect(() => { - flowRef.current = null; selectedFlowRef.current = null; reviewRequestRef.current += 1; + flowRef.current = null; selectedFlowRef.current = null; selectedNodeRef.current = null; reviewRequestRef.current += 1; credentialMutationRequestRef.current += 1; setFlows([]); setRecord(null); setSelectedFlowId(null); setRuns([]); setAgents([]); setAttachments([]); setAttachAgentId(""); setAgentsError(null); setFlowAttachmentError(null); setAttachmentMutationError(null); setAttachmentBusy(false); setError(null); setMigrationWarning(null); setNotice(null); + setCredentialFeedback(null); if (!projectId) return; const controller = new AbortController(); void loadLibrary(projectId, controller.signal); return () => controller.abort(); }, [projectId, loadLibrary]); @@ -192,6 +197,9 @@ export const NodesPage: FunctionComponent = () => { useEffect(() => { if (!selectedRunId) { setNodeRuns([]); setAttempts([]); setApprovals([]); return; } const controller = new AbortController(); void Promise.all([fetchNodeFlowNodeRuns(selectedRunId, controller.signal), fetchNodeFlowAttempts(selectedRunId, controller.signal), fetchNodeFlowApprovals(selectedRunId, controller.signal)]).then(([nodes, history, governed]) => { setNodeRuns(nodes.nodeRuns); setAttempts(history.attempts); setApprovals(governed.approvals); }).catch((requestError) => { if (!controller.signal.aborted) setError(errorMessage(requestError)); }); return () => controller.abort(); }, [selectedRunId]); const act = async (action: () => Promise): Promise => { setBusy(true); setError(null); setNotice(null); try { await action(); } catch (requestError) { if (mountedRef.current) setError(errorMessage(requestError)); } finally { if (mountedRef.current) setBusy(false); } }; + const editTitle = (value: string): void => { credentialMutationRequestRef.current += 1; setCredentialFeedback(null); setTitle(value); }; + const editDescription = (value: string): void => { credentialMutationRequestRef.current += 1; setCredentialFeedback(null); setDescription(value); }; + const editGraph = (update: (current: NodeFlowGraph) => NodeFlowGraph): void => { credentialMutationRequestRef.current += 1; setCredentialFeedback(null); setGraph(update); }; const refreshAttachmentData = (): void => { setAttachmentMutationError(null); if (projectId) void loadAgents(projectId); @@ -228,16 +236,111 @@ export const NodesPage: FunctionComponent = () => { if (projectRef.current === mutationProjectId && flowRef.current === flowId) setAttachmentBusy(false); }); }; - const selectFlow = (flowId: string): void => { const flow = flows.find((item) => item.id === flowId); if (!flow || !projectId) return; applyRecord(flow); void loadReview(projectId, flow.id); }; + const selectFlow = (flowId: string): void => { const flow = flows.find((item) => item.id === flowId); if (!flow || !projectId) return; credentialMutationRequestRef.current += 1; setCredentialFeedback(null); applyRecord(flow); void loadReview(projectId, flow.id); }; const createFlow = (): void => { if (!projectId) return; const targetProjectId = projectId; void act(async () => { const created = await createNodeFlowDraft(targetProjectId, { title: "Untitled automation", description: "", graph: createDefaultNodeFlowGraph() }); if (projectRef.current !== targetProjectId) return; const flow = await fetchNodeFlow(created.flowId); if (projectRef.current !== targetProjectId || !mountedRef.current) return; setFlows((current) => [flow, ...current.filter((item) => item.id !== flow.id)]); applyRecord(flow); setReview(created); setNotice("Draft created in the selected project."); }); }; const save = (): void => { if (!projectId || !record) return; void act(async () => { const result = await patchNodeFlowDraft(record.id, { projectId, draftRevision: record.version, title, description, graph }); if (result.conflict) { setError(`${result.conflict.message} Current revision is ${result.conflict.actualDraftRevision}.`); return; } const saved = await fetchNodeFlow(record.id); setFlows((current) => current.map((item) => item.id === saved.id ? saved : item)); applyRecord(saved); setReview(result.draft ?? null); setNotice("Draft saved to the canonical flow repository."); }); }; - const addNode = (summary: NodeDefinitionSummary): void => { void act(async () => { const definition = await fetchNodeDefinition(summary.type, summary.version); setDefinitions((current) => ({ ...current, [`${definition.type}@${definition.version}`]: definition })); let suffix = 1; while (graph.nodes.some((node) => node.id === `${definition.type}-${suffix}`)) suffix += 1; const node: NodeFlowNode = { id: `${definition.type}-${suffix}`, type: definition.type, title: definition.ui.label, description: definition.ui.description, definition: { type: definition.type, version: definition.version }, ports: definition.ports, widgetSchema: definition.ui.widgetSchema, data: {}, capabilities: definition.capabilities, sideEffect: definition.sideEffect, policy: definition.defaultPolicy, credentialBindings: [], position: { x: 80 + graph.nodes.length * 260, y: 100 } }; setGraph((current) => ({ ...current, nodes: [...current.nodes, node] })); setSelectedNodeId(node.id); }); }; + const addNode = (summary: NodeDefinitionSummary): void => { void act(async () => { const definition = await fetchNodeDefinition(summary.type, summary.version); setDefinitions((current) => ({ ...current, [`${definition.type}@${definition.version}`]: definition })); let suffix = 1; while (graph.nodes.some((node) => node.id === `${definition.type}-${suffix}`)) suffix += 1; const node: NodeFlowNode = { id: `${definition.type}-${suffix}`, type: definition.type, title: definition.ui.label, description: definition.ui.description, definition: { type: definition.type, version: definition.version }, ports: definition.ports, widgetSchema: definition.ui.widgetSchema, data: {}, capabilities: definition.capabilities, sideEffect: definition.sideEffect, policy: definition.defaultPolicy, credentialBindings: [], position: { x: 80 + graph.nodes.length * 260, y: 100 } }; setGraph((current) => ({ ...current, nodes: [...current.nodes, node] })); selectedNodeRef.current = node.id; credentialMutationRequestRef.current += 1; setCredentialFeedback(null); setSelectedNodeId(node.id); }); }; const validate = (): void => { if (!projectId || !record) return; void act(async () => setReview(await validateNodeFlowDraft(projectId, record.id))); }; const runDry = (): void => { if (!projectId || !record) return; void act(async () => setDryRun(await dryRunNodeFlowDraft(projectId, record.id))); }; const publish = (): void => { if (!projectId || !record || !review) return; void act(async () => { setReview(await publishNodeFlowDraft(projectId, record.id, review.draftRevision)); setNotice("Draft published after governed review."); }); }; const compare = (): void => { if (!projectId || !record || !review?.publishedVersion) return; const publishedVersion = review.publishedVersion; void act(async () => setDiff(await compareNodeFlowVersions(projectId, record.id, publishedVersion, record.version))); }; const rollback = (): void => { if (!projectId || !record || !review?.publishedVersion) return; void act(async () => { const next = await rollbackNodeFlow(projectId, record.id, review.publishedVersion!, record.version); const flow = await fetchNodeFlow(record.id); applyRecord(flow); setReview(next); }); }; const run = (): void => { if (!projectId || !record) return; void act(async () => { const result = await runNodeFlow(record.id, { projectId, input: {} }); setRuns((current) => [result.run, ...current]); setSelectedRunId(result.run.id); setNodeRuns(result.nodeRuns); setAttempts(result.attempts ?? []); }); }; + const selectNode = (nodeId: string | null): void => { + selectedNodeRef.current = nodeId; + credentialMutationRequestRef.current += 1; + setCredentialFeedback(null); + setSelectedNodeId(nodeId); + }; + const applyCredentialRecord = (saved: NodeFlowRecord, nextReview: NodeFlowDraftReview, nodeId: string): void => { + flowRef.current = saved.id; selectedFlowRef.current = saved.id; + setFlows((current) => current.map((item) => item.id === saved.id ? saved : item)); + setRecord(saved); setSelectedFlowId(saved.id); setTitle(saved.title); setDescription(saved.description); setGraph(saved.graph); + const nextSelectedNodeId = saved.graph.nodes.some((node) => node.id === nodeId) ? nodeId : null; + selectedNodeRef.current = nextSelectedNodeId; setSelectedNodeId(nextSelectedNodeId); setReview(nextReview); setDryRun(null); setDiff(null); + }; + const changeCredential = async (nodeId: string, slot: string, credentialId: string | null): Promise => { + if (!projectId || !record || selectedNodeRef.current !== nodeId) return "stale"; + const node = graph.nodes.find((candidate) => candidate.id === nodeId); + if (!node) return "stale"; + const currentCredentialId = node.credentialBindings?.find((binding) => binding.slot === slot)?.credentialId ?? null; + if (currentCredentialId === credentialId) { + setCredentialFeedback({ nodeId, slot, status: "saved", message: "This credential is already bound to the slot." }); + return "saved"; + } + const bindings: NonNullable = []; + let replaced = false; + for (const binding of node.credentialBindings ?? []) { + if (binding.slot !== slot) { bindings.push(binding); continue; } + if (!replaced && credentialId) bindings.push({ slot, credentialId }); + replaced = true; + } + if (!replaced && credentialId) bindings.push({ slot, credentialId }); + const nextGraph = updateNodeInGraph(graph, nodeId, { credentialBindings: bindings }); + const targetProjectId = projectId; + const flowId = record.id; + const draftRevision = record.version; + const requestId = ++credentialMutationRequestRef.current; + const isCurrent = (): boolean => mountedRef.current + && credentialMutationRequestRef.current === requestId + && projectRef.current === targetProjectId + && selectedFlowRef.current === flowId + && selectedNodeRef.current === nodeId; + setCredentialFeedback({ nodeId, slot, status: "saving", message: credentialId ? "Saving credential binding…" : "Removing credential binding…" }); + try { + const result = await patchNodeFlowDraft(flowId, { + projectId: targetProjectId, + draftRevision, + title, + description, + graph: nextGraph, + }); + if (!isCurrent()) return "stale"; + if (result.conflict) { + const conflictMessage = `${result.conflict.message} Loaded revision ${result.conflict.actualDraftRevision}; choose the credential again to retry.`; + try { + const [latest, nextReview] = await Promise.all([ + fetchNodeFlow(flowId), + validateNodeFlowDraft(targetProjectId, flowId), + ]); + if (!isCurrent()) return "stale"; + applyCredentialRecord(latest, nextReview, nodeId); + setCredentialFeedback({ nodeId, slot, status: "conflict", message: conflictMessage }); + } catch (refreshError) { + if (!isCurrent()) return "stale"; + setCredentialFeedback({ nodeId, slot, status: "conflict", message: `${conflictMessage} The latest draft could not be refreshed: ${errorMessage(refreshError)}` }); + } + return "conflict"; + } + const [saved, nextReview] = await Promise.all([ + fetchNodeFlow(flowId), + validateNodeFlowDraft(targetProjectId, flowId), + ]); + if (!isCurrent()) return "stale"; + applyCredentialRecord(saved, nextReview, nodeId); + const reviewedCredential = nextReview.requiredCredentials.find((credential) => credential.nodeId === nodeId && credential.slot === slot); + if (credentialId && reviewedCredential?.status === "denied") { + setCredentialFeedback({ nodeId, slot, status: "policy-denied", message: "The binding was saved, but current credential policy denies its use. Choose another credential or update it in Settings." }); + return "policy-denied"; + } + setCredentialFeedback({ nodeId, slot, status: "saved", message: credentialId ? "Credential binding saved and draft review refreshed." : "Credential binding removed and draft review refreshed." }); + return "saved"; + } catch (requestError) { + if (!isCurrent()) return "stale"; + const policyDenied = requestError instanceof NodeFlowDraftSaveError + ? requestError.status === 401 || requestError.status === 403 + : /policy|denied|forbidden|not authorized|permission/i.test(errorMessage(requestError)); + setCredentialFeedback({ + nodeId, + slot, + status: policyDenied ? "policy-denied" : "error", + message: policyDenied + ? `Credential binding was not saved because policy denied the change. ${errorMessage(requestError)}` + : `Credential binding was not saved. ${errorMessage(requestError)}`, + }); + return policyDenied ? "policy-denied" : "error"; + } + }; if (projectLoading) return
Loading project workspace…
; if (!selectedProject) return } title="Select a project" description="Flows, credentials, publications, and run history are always scoped to a project." />; @@ -247,9 +350,9 @@ export const NodesPage: FunctionComponent = () => { {migrationWarning ?
: null} {notice ?
{notice}
: null}
void act(async () => { await deleteNodeFlow(id); await loadLibrary(selectedProject.id); })} /> -
{record ?
: null}{record ? setGraph((current) => updateNodeInGraph(current, id, { position }))} /> : } title="No flows in this project" description="Create a draft to start from the canonical backend workspace." primaryAction={} />}
+
{record ?
: null}{record ? editGraph((current) => updateNodeInGraph(current, id, { position }))} /> : } title="No flows in this project" description="Create a draft to start from the canonical backend workspace." primaryAction={} />}
- {record ? item.nodeId === selectedNode?.id) ?? []} agents={agents} attachments={attachments} attachAgentId={attachAgentId} attachmentsLoading={agentsLoading || attachmentsLoading} attachmentError={attachmentMutationError ?? flowAttachmentError ?? agentsError} attaching={attachmentBusy} onAttachAgentIdChange={setAttachAgentId} onAttachAgent={attachAgent} onDetachAgent={detachAgent} onRetryAttachments={refreshAttachmentData} onNodeChange={(id, update) => setGraph((current) => updateNodeInGraph(current, id, update))} onRequestCredential={(nodeId, slot) => { if (projectId && record) void act(async () => { await requestNodeFlowCredential(projectId, record.id, nodeId, slot); setNotice("Credential binding request recorded; secret material remains outside the graph."); }); }} /> : null} + {record ? item.nodeId === selectedNode?.id) ?? []} projectId={selectedProject.id} flowId={record.id} credentialFeedback={credentialFeedback} onCredentialChange={changeCredential} agents={agents} attachments={attachments} attachAgentId={attachAgentId} attachmentsLoading={agentsLoading || attachmentsLoading} attachmentError={attachmentMutationError ?? flowAttachmentError ?? agentsError} attaching={attachmentBusy} onAttachAgentIdChange={setAttachAgentId} onAttachAgent={attachAgent} onDetachAgent={detachAgent} onRetryAttachments={refreshAttachmentData} onNodeChange={(id, update) => editGraph((current) => updateNodeInGraph(current, id, update))} /> : null}
{record ? <> void act(refreshRuns)} onCancel={() => { const active = runs.find((item) => item.id === selectedRunId); if (projectId && active) void act(async () => { await cancelNodeFlowRun(projectId, active.id); await refreshRuns(); }); }} onRetry={() => { const active = runs.find((item) => item.id === selectedRunId); if (projectId && active) void act(async () => { const result = await retryNodeFlowRun(projectId, active.id); setRuns((current) => [result.run, ...current]); setSelectedRunId(result.run.id); }); }} onApprovalDecision={(approvalId, decision) => void act(async () => { const result = await decideNodeFlowApproval(approvalId, decision); setRuns((current) => current.map((item) => item.id === result.run.id ? result.run : item)); setNodeRuns(result.nodeRuns); setAttempts(result.attempts ?? []); setApprovals((current) => current.map((item) => item.id === approvalId ? { ...item, status: result.status, decidedAt: result.decidedAt, decidedBy: result.decidedBy, decision: result.decision, updatedAt: result.updatedAt } : item)); })} /> : null} ; diff --git a/dashboard/src/v2/components/nodes/NodeCredentialPicker.tsx b/dashboard/src/v2/components/nodes/NodeCredentialPicker.tsx new file mode 100644 index 0000000000..06e0c2edcd --- /dev/null +++ b/dashboard/src/v2/components/nodes/NodeCredentialPicker.tsx @@ -0,0 +1,239 @@ +import type { FunctionComponent } from "preact"; +import { useCallback, useEffect, useRef, useState } from "preact/hooks"; +import { Check, KeyRound, Settings, ShieldAlert, Unlink } from "lucide-preact"; +import type { AutomationCredentialCompatibilityIssue } from "../../../../../src/contracts/automation-credential-types.js"; +import type { NodeDefinitionCredentialRequirement } from "../../../../../src/contracts/node-definition-types.js"; +import { + assessAutomationCredentialCompatibility, + fetchAutomationCredentials, + fetchCredentialHealth, +} from "../../lib/automation-credential-api.js"; +import { writeSettingsNavigationState } from "../../lib/settings-navigation-state.js"; +import { DropdownMenu, DropdownMenuItem } from "../ui/DropdownMenu.js"; + +export type CredentialSelectionResult = "saved" | "conflict" | "policy-denied" | "error" | "stale"; + +interface CredentialOption { + id: string; + name: string; + kind: string; + compatible: boolean; + reasons: string[]; +} + +interface NodeCredentialPickerProps { + projectId: string; + identity: string; + requirement: NodeDefinitionCredentialRequirement; + boundCredentialId: string | null; + disabled?: boolean; + onSelect: (credentialId: string | null) => Promise; +} + +const issueText = ( + issue: AutomationCredentialCompatibilityIssue, + missingCapabilities: string[], + allowedKinds: string[], +): string => { + switch (issue) { + case "backend_unavailable": return "Secure credential storage is unavailable."; + case "backend_insecure": return "Secure credential storage is not ready."; + case "not_configured": return "Credential setup is incomplete."; + case "not_active": return "Credential is not active."; + case "project_access_denied": return "Credential is not available to this project."; + case "kind_not_allowed": return `Requires one of these kinds: ${allowedKinds.join(", ")}.`; + case "capability_missing": return missingCapabilities.length > 0 + ? `Missing required access: ${missingCapabilities.join(", ")}.` + : "The credential does not grant the required access."; + } +}; + +export const NodeCredentialPicker: FunctionComponent = ({ + projectId, + identity, + requirement, + boundCredentialId, + disabled = false, + onSelect, +}) => { + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [options, setOptions] = useState([]); + const [backendReady, setBackendReady] = useState(null); + const [loadError, setLoadError] = useState(null); + const [selectingId, setSelectingId] = useState(null); + const requestRef = useRef(0); + const triggerRef = useRef(null); + + const setPickerOpen = useCallback((nextOpen: boolean): void => { + setOpen(nextOpen); + if (!nextOpen && typeof window !== "undefined") { + window.setTimeout(() => triggerRef.current?.focus({ preventScroll: true }), 50); + } + }, []); + + const load = useCallback(async (): Promise => { + const requestId = ++requestRef.current; + setLoading(true); + setLoadError(null); + setOptions([]); + try { + const [credentials, health] = await Promise.all([ + fetchAutomationCredentials(projectId), + fetchCredentialHealth(), + ]); + const ready = health.available + && health.secure + && typeof health.keyId === "string" + && health.keyId.length > 0 + && health.keyVersion !== null; + const assessments = await Promise.all(credentials.map(async (credential) => ({ + id: credential.id, + name: credential.name, + kind: credential.kind, + assessment: await assessAutomationCredentialCompatibility(projectId, credential.id, { + allowedKinds: requirement.allowedKinds, + requiredCapabilities: requirement.requiredCapabilities, + }), + }))); + if (requestRef.current !== requestId) return; + setBackendReady(ready); + setOptions(assessments.map(({ id, name, kind, assessment }) => ({ + id, + name, + kind, + compatible: ready && assessment.compatible, + reasons: assessment.issues.map((issue) => issueText( + issue, + assessment.missingCapabilities, + requirement.allowedKinds, + )), + }))); + } catch { + if (requestRef.current !== requestId) return; + setBackendReady(false); + setLoadError("Credential metadata could not be loaded. Retry or open Settings to review credential access."); + } finally { + if (requestRef.current === requestId) setLoading(false); + } + }, [projectId, requirement.allowedKinds, requirement.requiredCapabilities]); + + useEffect(() => { + requestRef.current += 1; + setOpen(false); + setOptions([]); + setBackendReady(null); + setLoadError(null); + setSelectingId(null); + }, [identity]); + + useEffect(() => { + if (!open) return; + void load(); + return () => { requestRef.current += 1; }; + }, [open, load]); + + const choose = async (credentialId: string | null): Promise => { + const pendingId = credentialId ?? "__unbind__"; + if (selectingId || disabled) return; + setSelectingId(pendingId); + const result = await onSelect(credentialId); + setSelectingId(null); + if (result === "saved") setPickerOpen(false); + }; + + const compatibleOptions = options.filter((option) => option.compatible); + const unavailableOptions = options.filter((option) => !option.compatible); + const hasCompatibleChoice = compatibleOptions.some((option) => option.id !== boundCredentialId); + const currentOption = options.find((option) => option.id === boundCredentialId); + + return ( + +
+

{requirement.label}

+

+ Choose a project-visible credential with {requirement.requiredCapabilities.join(", ") || "the declared"} access. Secret values never enter this page. +

+
+ {loading ?

Checking credential compatibility…

: null} + {loadError ?
{loadError}
: null} + {!loading && backendReady === false ? ( +
+
+ ) : null} + {!loading && compatibleOptions.length > 0 ? ( +
+

Compatible

+ {compatibleOptions.map((option) => ( + void choose(option.id)} + > + {option.name}{option.kind} + {option.id === boundCredentialId ? : null} + + ))} +
+ ) : null} + {!loading && unavailableOptions.length > 0 ? ( +
+

Unavailable for this slot

+ {unavailableOptions.map((option) => ( +
+

{option.name} · {option.kind}

+

{option.reasons.join(" ") || "This credential is not compatible with the slot policy."}

+
+ ))} +
+ ) : null} + {!loading && !loadError && !hasCompatibleChoice ? ( + + ) : null} + {boundCredentialId ? ( + void choose(null)} + > + + ) : null} +
+ )} + > + + + ); +}; diff --git a/dashboard/src/v2/components/nodes/NodeFlowInspector.tsx b/dashboard/src/v2/components/nodes/NodeFlowInspector.tsx index 3deec2cd55..98beaa5ab9 100644 --- a/dashboard/src/v2/components/nodes/NodeFlowInspector.tsx +++ b/dashboard/src/v2/components/nodes/NodeFlowInspector.tsx @@ -1,5 +1,6 @@ import type { FunctionComponent } from "preact"; import { Link2, Unlink } from "lucide-preact"; +import type { NodeDefinitionCredentialRequirement } from "../../../../../src/contracts/node-definition-types.js"; import type { AgentPreset, NodeFlowJsonObject, @@ -15,6 +16,16 @@ import { buildValidationMessagesByField, } from "../../lib/node-flow-view-models.js"; import { NodeWidgetField } from "./NodeWidgetField.js"; +import { NodeCredentialPicker, type CredentialSelectionResult } from "./NodeCredentialPicker.js"; + +export type CredentialBindingSaveStatus = "saving" | "saved" | "conflict" | "policy-denied" | "error"; + +export interface CredentialBindingFeedback { + nodeId: string; + slot: string; + status: CredentialBindingSaveStatus; + message: string; +} interface NodeFlowInspectorProps { selectedNode: NodeFlowNode | null; @@ -30,9 +41,12 @@ interface NodeFlowInspectorProps { onDetachAgent: (agentPresetId: string) => void; onRetryAttachments?: () => void; onNodeChange: (nodeId: string, update: Partial) => void; + projectId: string; + flowId: string; definition?: NodeDefinitionManifest | null; requiredCredentials?: NodeFlowRequiredCredential[]; - onRequestCredential?: (nodeId: string, slot: string) => void; + credentialFeedback?: CredentialBindingFeedback | null; + onCredentialChange: (nodeId: string, slot: string, credentialId: string | null) => Promise; } const inputClass = "w-full rounded-xl border border-black/[0.08] bg-white/75 px-3 py-2 text-sm text-slate-800 shadow-sm outline-none transition focus:border-signal-500/50 focus:ring-2 focus:ring-signal-500/20 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-slate-100"; @@ -51,9 +65,12 @@ export const NodeFlowInspector: FunctionComponent = ({ onDetachAgent, onRetryAttachments, onNodeChange, + projectId, + flowId, definition = null, requiredCredentials = [], - onRequestCredential, + credentialFeedback = null, + onCredentialChange, }) => { const messagesByField = buildValidationMessagesByField(validation); @@ -67,6 +84,15 @@ export const NodeFlowInspector: FunctionComponent = ({ const widgetSchema = definition?.ui?.widgetSchema ?? selectedNode.widgetSchema; const data = applyWidgetDefaults(widgetSchema, selectedNode.data); + const credentialRequirements: NodeDefinitionCredentialRequirement[] = definition + ? definition.credentials + : requiredCredentials.map((credential) => ({ + slot: credential.slot, + label: credential.slot, + required: credential.required, + allowedKinds: credential.allowedKinds, + requiredCapabilities: credential.requiredCapabilities, + })); const updateDataField = (fieldId: string, value: NodeFlowJsonValue): void => { onNodeChange(selectedNode.id, { @@ -130,13 +156,42 @@ export const NodeFlowInspector: FunctionComponent = ({

Credential bindings

- {requiredCredentials.length === 0 ?

This node does not request credentials.

: requiredCredentials.map((credential) => ( -
-
{credential.slot}{credential.status}
-

{credential.allowedKinds.join(", ")} · secret value never displayed

- {credential.status !== "bound" && onRequestCredential ? : null} -
- ))} + {credentialRequirements.length === 0 ?

This node does not request credentials.

: credentialRequirements.map((requirement) => { + const reviewCredential = requiredCredentials.find((credential) => credential.slot === requirement.slot); + const binding = selectedNode.credentialBindings?.find((entry) => entry.slot === requirement.slot) ?? null; + const feedback = credentialFeedback?.nodeId === selectedNode.id && credentialFeedback.slot === requirement.slot + ? credentialFeedback + : null; + const status = binding + ? reviewCredential?.status ?? "bound" + : "missing"; + return ( +
+
+ {requirement.label} + {status} +
+

{requirement.allowedKinds.join(", ")} · {requirement.requiredCapabilities.join(", ") || "declared"} access · secret value never displayed

+ onCredentialChange(selectedNode.id, requirement.slot, credentialId)} + /> + {feedback ? ( +

+ {feedback.message} +

+ ) : null} +
+ ); + })}
diff --git a/dashboard/src/v2/lib/node-flow-api.ts b/dashboard/src/v2/lib/node-flow-api.ts index c442ccfc55..b06210c037 100644 --- a/dashboard/src/v2/lib/node-flow-api.ts +++ b/dashboard/src/v2/lib/node-flow-api.ts @@ -44,9 +44,18 @@ export interface NodeDefinitionSummary { ports: NodeDefinitionManifest["ports"]; } -export interface PatchNodeFlowDraftResponse { - draft?: NodeFlowDraftReview; - conflict?: NodeFlowConcurrencyConflict; +export type PatchNodeFlowDraftResponse = + | { draft: NodeFlowDraftReview; conflict?: never } + | { draft?: never; conflict: NodeFlowConcurrencyConflict }; + +export class NodeFlowDraftSaveError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = "NodeFlowDraftSaveError"; + } } export interface NodeFlowDryRunResponse { @@ -84,10 +93,27 @@ export const createNodeFlowDraft = async (projectId: string, input: CreateNodeFl method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), }); -export const patchNodeFlowDraft = async (flowId: string, input: PatchNodeFlowDraftInput): Promise => - fetchJson(`/api/node-flow-drafts/${encodeURIComponent(flowId)}`, { - method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(input), +export const patchNodeFlowDraft = async (flowId: string, input: PatchNodeFlowDraftInput): Promise => { + const path = `/api/node-flow-drafts/${encodeURIComponent(flowId)}`; + const response = await fetch(path, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + cache: "no-store", }); + const body = await response.json().catch(() => ({})) as Partial & { error?: unknown; message?: unknown }; + if (response.status === 409 && body.conflict) return { conflict: body.conflict }; + if (!response.ok) { + const message = typeof body.error === "string" + ? body.error + : typeof body.message === "string" + ? body.message + : `Request failed: ${path}`; + throw new NodeFlowDraftSaveError(response.status, message); + } + if (!body.draft) throw new NodeFlowDraftSaveError(response.status, "The draft save response did not include a review."); + return { draft: body.draft }; +}; export const validateNodeFlowDraft = async (projectId: string, flowId: string, signal?: AbortSignal): Promise => fetchJson(`/api/node-flow-drafts/${encodeURIComponent(flowId)}/validate`, { @@ -99,11 +125,6 @@ export const dryRunNodeFlowDraft = async (projectId: string, flowId: string, inp method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId, input }), }); -export const requestNodeFlowCredential = async (projectId: string, flowId: string, nodeId: string, slot: string): Promise> => - fetchJson>(`/api/node-flow-drafts/${encodeURIComponent(flowId)}/credential-requests`, { - method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId, nodeId, slot }), - }); - export const publishNodeFlowDraft = async (projectId: string, flowId: string, draftRevision: number): Promise => fetchJson(`/api/node-flow-drafts/${encodeURIComponent(flowId)}/publish`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectId, draftRevision, publishedBy: "dashboard" }), diff --git a/docs-web/content/docs/user-dashboard-node-flows.mdx b/docs-web/content/docs/user-dashboard-node-flows.mdx index 809bb5783e..c767364e31 100644 --- a/docs-web/content/docs/user-dashboard-node-flows.mdx +++ b/docs-web/content/docs/user-dashboard-node-flows.mdx @@ -12,7 +12,9 @@ The former browser graph at `codeux:nodes-canvas:v1` is eligible for one import The registry list returns flat versioned palette summaries. Selecting a definition loads the full manifest from the node-type detail endpoint, including nested `ui.widgetSchema`, configuration schema, policies, documentation, and deprecation metadata. The inspector renders from that full contract. Graphs reference a definition version and store non-secret configuration and credential ids; they do not contain custom-node source or resolved credentials. -Credential slots display metadata-only states such as bound, missing, or denied and can request a binding. Secret values remain behind the credential broker and are excluded from graphs and browser output. +Credential slots use the versioned definition's allowed kinds and required capabilities to offer project-visible credential metadata. Only active, configured credentials with project access and a healthy secure backend are selectable; unavailable entries explain the operator-facing reason without exposing secret or key-custody details, and an empty compatible set links directly to **Settings → Integrations**. + +Selecting, replacing, or removing a credential updates only that slot in the node's canonical `credentialBindings` and immediately saves the complete draft through the current optimistic revision. The dashboard then adopts the canonical flow revision and refreshes governed review. Saving, saved, policy-denial, and error states are announced. A revision conflict loads the latest draft, preserves the selected slot workflow and sibling edits, and requires the operator to choose again rather than replaying the stale mutation. Credential plaintext remains behind the broker and is excluded from graph data, component state, and browser output. The complete governed built-in set currently registered with executable handlers is `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, `condition`, `switch`, `foreach`, `merge`, `delay`, `approval`, `email_draft`, `email_send`, `execute_subflow`, `webhook_trigger`, and `output`. diff --git a/docs-web/user/dashboard/node-flows.md b/docs-web/user/dashboard/node-flows.md index 8211b4f256..5668b99750 100644 --- a/docs-web/user/dashboard/node-flows.md +++ b/docs-web/user/dashboard/node-flows.md @@ -12,7 +12,9 @@ The former browser graph at `codeux:nodes-canvas:v1` is eligible for one import The registry list returns flat versioned palette summaries. Selecting a definition loads the full manifest from the node-type detail endpoint, including nested `ui.widgetSchema`, configuration schema, policies, documentation, and deprecation metadata. The inspector renders from that full contract. Graphs reference a definition version and store non-secret configuration and credential ids; they do not contain custom-node source or resolved credentials. -Credential slots display metadata-only states such as bound, missing, or denied and can request a binding. Secret values remain behind the credential broker and are excluded from graphs and browser output. +Credential slots use the versioned definition's allowed kinds and required capabilities to offer project-visible credential metadata. Only active, configured credentials with project access and a healthy secure backend are selectable; unavailable entries explain the operator-facing reason without exposing secret or key-custody details, and an empty compatible set links directly to **Settings → Integrations**. + +Selecting, replacing, or removing a credential updates only that slot in the node's canonical `credentialBindings` and immediately saves the complete draft through the current optimistic revision. The dashboard then adopts the canonical flow revision and refreshes governed review. Saving, saved, policy-denial, and error states are announced. A revision conflict loads the latest draft, preserves the selected slot workflow and sibling edits, and requires the operator to choose again rather than replaying the stale mutation. Credential plaintext remains behind the broker and is excluded from graph data, component state, and browser output. The complete governed built-in set currently registered with executable handlers is `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, `condition`, `switch`, `foreach`, `merge`, `delay`, `approval`, `email_draft`, `email_send`, `execute_subflow`, `webhook_trigger`, and `output`. diff --git a/docs/dashboard/node-flows.md b/docs/dashboard/node-flows.md index 4a2b50aeb9..66ad383700 100644 --- a/docs/dashboard/node-flows.md +++ b/docs/dashboard/node-flows.md @@ -12,7 +12,9 @@ The former browser graph at `codeux:nodes-canvas:v1` is eligible for one import `GET /api/node-flow-catalog` returns flat versioned palette summaries. `GET /api/node-flow-catalog/:nodeType` returns the full `NodeDefinitionManifest`, including nested `ui.widgetSchema`, configuration schema, policies, documentation, and deprecation metadata. The inspector renders from that full contract. Graphs reference a definition version and store non-secret configuration and credential ids; they do not contain custom-node source or resolved credentials. -Credential slots display metadata-only states such as bound, missing, or denied and can request a binding. Secret values remain behind the credential broker and are excluded from graphs, browser output, logs, and documentation examples. +Credential slots use the versioned definition's allowed kinds and required capabilities to offer project-visible credential metadata. Only active, configured credentials with project access and a healthy secure backend are selectable; unavailable entries explain the operator-facing reason without exposing secret or key-custody details, and an empty compatible set links directly to **Settings → Integrations**. + +Selecting, replacing, or removing a credential updates only that slot in the node's canonical `credentialBindings` and immediately saves the complete draft through the current optimistic revision. The dashboard then adopts the canonical flow revision and refreshes governed review. Saving, saved, policy-denial, and error states are announced. A revision conflict loads the latest draft, preserves the selected slot workflow and sibling edits, and requires the operator to choose again rather than replaying the stale mutation. Credential plaintext remains behind the broker and is excluded from graph data, component state, browser output, logs, and documentation examples. The complete governed built-in set currently registered with executable handlers is `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, `condition`, `switch`, `foreach`, `merge`, `delay`, `approval`, `email_draft`, `email_send`, `execute_subflow`, `webhook_trigger`, and `output`. diff --git a/tests/dashboard/v2/nodes-inspector.test.tsx b/tests/dashboard/v2/nodes-inspector.test.tsx index 61ca404121..6f0d9eb704 100644 --- a/tests/dashboard/v2/nodes-inspector.test.tsx +++ b/tests/dashboard/v2/nodes-inspector.test.tsx @@ -1,8 +1,8 @@ /** @vitest-environment jsdom */ -import { cleanup, fireEvent, render, screen } from "@testing-library/preact"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact"; import userEvent from "@testing-library/user-event"; import * as matchers from "@testing-library/jest-dom/matchers"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { h } from "preact"; import { createInitialNodeCanvasGraph, @@ -14,6 +14,15 @@ import { import { NodeInspector } from "../../../dashboard/src/v2/components/nodes/NodeInspector.js"; import { NodePalette } from "../../../dashboard/src/v2/components/nodes/NodePalette.js"; import { NodeValidationPanel } from "../../../dashboard/src/v2/components/nodes/NodeValidationPanel.js"; +import { NodeFlowInspector } from "../../../dashboard/src/v2/components/nodes/NodeFlowInspector.js"; + +const credentialApi = vi.hoisted(() => ({ + fetchAutomationCredentials: vi.fn(), + fetchCredentialHealth: vi.fn(), + assessAutomationCredentialCompatibility: vi.fn(), +})); +vi.mock("../../../dashboard/src/v2/lib/automation-credential-api.js", () => credentialApi); +vi.mock("../../../dashboard/src/v2/hooks/use-reduced-motion.js", () => ({ useReducedMotion: () => true, useResolvedMotionDuration: (value: T): T => value })); expect.extend(matchers); @@ -49,6 +58,15 @@ const renderInspector = (node: NodeCanvasNode | null, graph = createInitialNodeC }; describe("nodes inspector panels", () => { + beforeEach(() => { + credentialApi.fetchCredentialHealth.mockResolvedValue({ available: true, secure: true, provider: "secure", keyId: "key", keyVersion: 1 }); + credentialApi.fetchAutomationCredentials.mockResolvedValue([]); + credentialApi.assessAutomationCredentialCompatibility.mockResolvedValue({ + credentialId: "credential-1", projectId: "project-1", compatible: true, backendReady: true, + configured: true, active: true, projectAccess: true, kindAllowed: true, capabilitiesAllowed: true, + missingCapabilities: [], issues: [], metadata: null, + }); + }); it("emits governed registry definitions from the palette", async () => { const user = userEvent.setup(); const onCreateNode = vi.fn(); @@ -174,4 +192,110 @@ describe("nodes inspector panels", () => { expect(onSelectEdge).toHaveBeenCalledWith("edge-missing-target"); expect(onFocusEdge).toHaveBeenCalledWith("edge-missing-target"); }); + + const credentialNode = { + id: "provider-1", + type: "provider_prompt", + title: "Provider prompt", + description: "Run a prompt.", + definition: { type: "provider_prompt", version: 1 }, + data: { prompt: "Public configuration only" }, + credentialBindings: [], + position: { x: 10, y: 10 }, + }; + const credentialDefinition = { + type: "provider_prompt", version: 1, executable: true, executionKind: "provider" as const, + configurationSchema: { type: "object" as const }, + ui: { label: "Provider prompt", description: "Run a prompt.", category: "Providers", widgetSchema: { fields: [] } }, + ports: [], credentials: [{ slot: "provider", label: "Provider connection", required: true, allowedKinds: ["provider"], requiredCapabilities: ["read"] }], + capabilities: [], sideEffect: "none" as const, defaultPolicy: {}, documentation: "", deprecation: { deprecated: false }, + }; + const renderCredentialInspector = (onCredentialChange = vi.fn(async () => "saved" as const)) => { + render( + , + ); + return onCredentialChange; + }; + + it("shows only compatible credentials as selectable and explains unavailable metadata", async () => { + const user = userEvent.setup(); + credentialApi.fetchAutomationCredentials.mockResolvedValue([ + { id: "credential-good", name: "Provider read token", kind: "provider" }, + { id: "credential-bad", name: "Revoked deployment token", kind: "http", value: "plaintext-canary" }, + ]); + credentialApi.assessAutomationCredentialCompatibility.mockImplementation(async (_projectId: string, credentialId: string) => credentialId === "credential-good" ? { + credentialId, projectId: "project-1", compatible: true, backendReady: true, configured: true, active: true, + projectAccess: true, kindAllowed: true, capabilitiesAllowed: true, missingCapabilities: [], issues: [], metadata: null, + } : { + credentialId, projectId: "project-1", compatible: false, backendReady: true, configured: true, active: false, + projectAccess: true, kindAllowed: false, capabilitiesAllowed: true, missingCapabilities: [], issues: ["not_active", "kind_not_allowed"], + metadata: { value: "plaintext-canary" }, + }); + renderCredentialInspector(); + + await user.click(screen.getByRole("button", { name: "Choose credential for Provider connection" })); + + expect(await screen.findByRole("menuitem", { name: /Provider read token/ })).toBeInTheDocument(); + expect(screen.queryByRole("menuitem", { name: /Revoked deployment token/ })).not.toBeInTheDocument(); + expect(screen.getByText(/Credential is not active/)).toBeInTheDocument(); + expect(screen.getByText(/Requires one of these kinds: provider/)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent("plaintext-canary"); + }); + + it("blocks selection when secure storage is unavailable and links directly to Settings", async () => { + const user = userEvent.setup(); + credentialApi.fetchCredentialHealth.mockResolvedValue({ available: false, secure: false, provider: "secure", keyId: null, keyVersion: null, reason: "low-level backend detail" }); + credentialApi.fetchAutomationCredentials.mockResolvedValue([{ id: "credential-1", name: "Provider token", kind: "provider" }]); + credentialApi.assessAutomationCredentialCompatibility.mockResolvedValue({ + credentialId: "credential-1", projectId: "project-1", compatible: false, backendReady: false, configured: true, + active: true, projectAccess: true, kindAllowed: true, capabilitiesAllowed: true, missingCapabilities: [], + issues: ["backend_unavailable"], metadata: null, + }); + renderCredentialInspector(); + + await user.click(screen.getByRole("button", { name: "Choose credential for Provider connection" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("Secure credential storage is unavailable"); + expect(screen.getByRole("menuitem", { name: "Open credential Settings" })).toHaveAttribute("href", "/config"); + expect(screen.queryByRole("menuitem", { name: /Provider token/ })).not.toBeInTheDocument(); + expect(document.body).not.toHaveTextContent("low-level backend detail"); + }); + + it("supports keyboard selection and Escape while restoring focus to the trigger", async () => { + const user = userEvent.setup(); + const onCredentialChange = vi.fn(async () => "saved" as const); + credentialApi.fetchAutomationCredentials.mockResolvedValue([{ id: "credential-1", name: "Provider token", kind: "provider" }]); + renderCredentialInspector(onCredentialChange); + const trigger = screen.getByRole("button", { name: "Choose credential for Provider connection" }); + + trigger.focus(); + await user.keyboard("{Enter}"); + const option = await screen.findByRole("menuitem", { name: /Provider token/ }); + option.focus(); + await user.keyboard("{Enter}"); + + expect(onCredentialChange).toHaveBeenCalledWith("provider-1", "provider", "credential-1"); + await waitFor(() => expect(trigger).toHaveFocus()); + + await user.keyboard("{Enter}"); + await screen.findByRole("menu", { name: "Credential picker for Provider connection" }); + await user.keyboard("{Escape}"); + await waitFor(() => expect(trigger).toHaveFocus()); + }); }); diff --git a/tests/dashboard/v2/nodes-page.test.tsx b/tests/dashboard/v2/nodes-page.test.tsx index a6540dae23..fc99275375 100644 --- a/tests/dashboard/v2/nodes-page.test.tsx +++ b/tests/dashboard/v2/nodes-page.test.tsx @@ -10,19 +10,81 @@ import { ProjectDataContext } from "../../../dashboard/src/v2/context/project-da const api = vi.hoisted(() => ({ fetchNodeFlows: vi.fn(), fetchNodeFlowCatalog: vi.fn(), createNodeFlowDraft: vi.fn(), fetchNodeFlow: vi.fn(), fetchNodeFlowRuns: vi.fn(), fetchNodeFlowNodeRuns: vi.fn(), fetchNodeFlowAttempts: vi.fn(), fetchNodeFlowApprovals: vi.fn(), fetchNodeFlowAgentSkills: vi.fn(), attachNodeFlowToAgent: vi.fn(), detachNodeFlowFromAgent: vi.fn(), decideNodeFlowApproval: vi.fn(), patchNodeFlowDraft: vi.fn(), fetchNodeDefinition: vi.fn(), validateNodeFlowDraft: vi.fn(), deleteNodeFlow: vi.fn() })); const agentApi = vi.hoisted(() => ({ fetchAgentPresets: vi.fn() })); +const credentialApi = vi.hoisted(() => ({ fetchAutomationCredentials: vi.fn(), fetchCredentialHealth: vi.fn(), assessAutomationCredentialCompatibility: vi.fn() })); vi.mock("../../../dashboard/src/v2/lib/node-flow-api.js", async (original) => ({ ...(await original()), ...api })); vi.mock("../../../dashboard/src/v2/lib/agent-preset-api.js", async (original) => ({ ...(await original()), ...agentApi })); +vi.mock("../../../dashboard/src/v2/lib/automation-credential-api.js", () => credentialApi); vi.mock("../../../dashboard/src/v2/hooks/use-reduced-motion.js", () => ({ useReducedMotion: () => true, useResolvedMotionDuration: (value: T): T => value })); const flow = { id: "flow-1", projectId: "project-1", title: "Release automation", description: "Governed", graph: { schemaVersion: 2 as const, nodes: [{ id: "input-1", type: "input", title: "Input", definition: { type: "input", version: 1 }, position: { x: 40, y: 40 } }], edges: [] }, version: 2, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }; const agent = { id: "agent-1", projectId: "project-1", name: "Release Agent", description: "Release helper", instructionMarkdown: "PRIVATE AGENT INSTRUCTIONS", labels: [], sourcePath: null, sourceScope: null, sourceUpdatedAt: null, sourceImportedAt: null, sourceExists: false, syncStatus: "manual", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }; const attachment = { flowId: "flow-1", projectId: "project-1", agentPresetId: "agent-1", skillName: "Release skill", description: "Governed", createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z" }; const context = { projects: [{ id: "project-1", name: "Test project" }], selectedProjectId: "project-1", selectedProject: { id: "project-1", name: "Test project" }, loading: false, error: null, refreshProjects: async () => undefined, selectProject: async () => undefined, createProject: async () => { throw new Error("unused"); }, updateProject: async () => { throw new Error("unused"); }, deleteProject: async () => undefined }; +const credentialDefinition = { + type: "provider_prompt", version: 1, executable: true, executionKind: "provider", configurationSchema: { type: "object" }, + ui: { label: "Provider prompt", description: "Prompt", category: "Providers", widgetSchema: { fields: [] } }, ports: [], + credentials: [{ slot: "provider", label: "Provider connection", required: true, allowedKinds: ["provider"], requiredCapabilities: ["read"] }], + capabilities: [], sideEffect: "none", defaultPolicy: {}, documentation: "", deprecation: { deprecated: false }, +}; +const credentialFlow = { + ...flow, + graph: { + schemaVersion: 2 as const, + nodes: [{ + id: "provider-1", type: "provider_prompt", title: "Provider prompt", description: "Prompt", + definition: { type: "provider_prompt", version: 1 }, data: { prompt: "Keep this configuration" }, + credentialBindings: [{ slot: "audit", credentialId: "credential-audit" }], position: { x: 40, y: 40 }, + }], + edges: [], + }, +}; +const credentialMetadata = (id: string, name: string) => ({ + id, name, kind: "provider", scope: "project", projectId: "project-1", managementProjectId: "project-1", + allowedProjectIds: [], capabilities: ["read"], status: "active", configured: true, keyId: "hidden-key", + keyVersion: 1, version: 1, lastValidatedAt: null, validationStatus: "valid", createdAt: "now", updatedAt: "now", +}); describe("NodesPage governed workspace", () => { const review = { flowId: "flow-1", projectId: "project-1", name: "Release automation", description: "Governed", draftRevision: 2, nodeCount: 1, edgeCount: 0, valid: true, validationIssues: [], policyFindings: [], requiredCredentials: [], requestedCapabilities: [], sideEffectDiffs: [], publishedVersion: 1 }; + const credentialReview = (currentFlow: typeof credentialFlow, status: "bound" | "missing" | "denied" = "bound") => { + const credentialId = currentFlow.graph.nodes[0]?.credentialBindings.find((binding) => binding.slot === "provider")?.credentialId ?? null; + return { + ...review, + draftRevision: currentFlow.version, + requiredCredentials: [{ + nodeId: "provider-1", slot: "provider", allowedKinds: ["provider"], requiredCapabilities: ["read"], required: true, + credentialId, status: credentialId ? status : "missing", backendReady: credentialId ? true : null, configured: credentialId ? true : null, + active: credentialId ? true : null, projectAccess: credentialId ? true : null, kindAllowed: credentialId ? true : null, + capabilitiesAllowed: credentialId ? true : null, missingCapabilities: credentialId ? [] : ["read"], compatibilityIssues: [], + }], + }; + }; + const setupCredentialFlow = (initialFlow: typeof credentialFlow = credentialFlow) => { + let canonical = initialFlow; + api.fetchNodeFlows.mockResolvedValue({ flows: [canonical] }); + api.fetchNodeDefinition.mockResolvedValue(credentialDefinition); + api.fetchNodeFlow.mockImplementation(async () => canonical); + api.validateNodeFlowDraft.mockImplementation(async () => credentialReview(canonical)); + credentialApi.fetchAutomationCredentials.mockResolvedValue([ + credentialMetadata("credential-old", "Existing provider token"), + credentialMetadata("credential-new", "Replacement provider token"), + ]); + credentialApi.assessAutomationCredentialCompatibility.mockImplementation(async (_projectId: string, credentialId: string) => ({ + credentialId, projectId: "project-1", compatible: true, backendReady: true, configured: true, active: true, + projectAccess: true, kindAllowed: true, capabilitiesAllowed: true, missingCapabilities: [], issues: [], metadata: null, + })); + return { + current: () => canonical, + updateFromPatch: (input: { graph: typeof credentialFlow.graph }) => { + canonical = { ...canonical, graph: input.graph, version: canonical.version + 1, updatedAt: "2026-01-01T00:01:00.000Z" }; + return canonical; + }, + replace: (next: typeof credentialFlow) => { canonical = next; }, + }; + }; + beforeEach(() => { api.patchNodeFlowDraft.mockReset(); api.fetchNodeFlow.mockReset(); }); beforeEach(() => { api.validateNodeFlowDraft.mockResolvedValue(review); }); - beforeEach(() => { window.localStorage.clear(); api.fetchNodeFlows.mockResolvedValue({ flows: [flow] }); api.fetchNodeFlowCatalog.mockResolvedValue({ nodes: [{ type: "input", version: 1, executable: true, executionKind: "local", label: "Input", description: "Input", category: "Core", credentials: [], capabilities: [], sideEffect: "none", ports: [] }] }); api.fetchNodeFlowRuns.mockResolvedValue({ runs: [] }); api.fetchNodeFlowNodeRuns.mockResolvedValue({ nodeRuns: [] }); api.fetchNodeFlowAttempts.mockResolvedValue({ attempts: [] }); api.fetchNodeFlowApprovals.mockResolvedValue({ approvals: [] }); api.fetchNodeFlowAgentSkills.mockResolvedValue([]); api.attachNodeFlowToAgent.mockResolvedValue(attachment); api.detachNodeFlowFromAgent.mockResolvedValue(undefined); agentApi.fetchAgentPresets.mockResolvedValue([agent]); api.fetchNodeDefinition.mockResolvedValue({ type: "input", version: 1, executable: true, executionKind: "local", configurationSchema: { type: "object" }, ui: { label: "Input", description: "Input", category: "Core", widgetSchema: { fields: [] } }, ports: [], credentials: [], capabilities: [], sideEffect: "none", defaultPolicy: {}, documentation: "", deprecation: { deprecated: false } }); }); + beforeEach(() => { window.localStorage.clear(); api.fetchNodeFlows.mockResolvedValue({ flows: [flow] }); api.fetchNodeFlowCatalog.mockResolvedValue({ nodes: [{ type: "input", version: 1, executable: true, executionKind: "local", label: "Input", description: "Input", category: "Core", credentials: [], capabilities: [], sideEffect: "none", ports: [] }] }); api.fetchNodeFlowRuns.mockResolvedValue({ runs: [] }); api.fetchNodeFlowNodeRuns.mockResolvedValue({ nodeRuns: [] }); api.fetchNodeFlowAttempts.mockResolvedValue({ attempts: [] }); api.fetchNodeFlowApprovals.mockResolvedValue({ approvals: [] }); api.fetchNodeFlowAgentSkills.mockResolvedValue([]); api.attachNodeFlowToAgent.mockResolvedValue(attachment); api.detachNodeFlowFromAgent.mockResolvedValue(undefined); agentApi.fetchAgentPresets.mockResolvedValue([agent]); api.fetchNodeDefinition.mockResolvedValue({ type: "input", version: 1, executable: true, executionKind: "local", configurationSchema: { type: "object" }, ui: { label: "Input", description: "Input", category: "Core", widgetSchema: { fields: [] } }, ports: [], credentials: [], capabilities: [], sideEffect: "none", defaultPolicy: {}, documentation: "", deprecation: { deprecated: false } }); credentialApi.fetchAutomationCredentials.mockResolvedValue([]); credentialApi.fetchCredentialHealth.mockResolvedValue({ available: true, secure: true, provider: "secure", keyId: "key", keyVersion: 1 }); credentialApi.assessAutomationCredentialCompatibility.mockResolvedValue({ credentialId: "credential-1", projectId: "project-1", compatible: true, backendReady: true, configured: true, active: true, projectAccess: true, kindAllowed: true, capabilitiesAllowed: true, missingCapabilities: [], issues: [], metadata: null }); }); afterEach(() => { cleanup(); vi.clearAllMocks(); }); it("loads a project flow library and registry-backed editor", async () => { @@ -203,4 +265,122 @@ describe("NodesPage governed workspace", () => { await user.click(screen.getByRole("button", { name: "Save draft" })); expect(await screen.findByRole("alert")).toHaveTextContent("Current revision is 3"); }); + + it("binds a compatible credential immediately and refreshes the canonical review", async () => { + const user = userEvent.setup(); + const state = setupCredentialFlow(); + api.patchNodeFlowDraft.mockImplementation(async (_flowId: string, input: { graph: typeof credentialFlow.graph }) => { + const saved = state.updateFromPatch(input); + return { draft: credentialReview(saved) }; + }); + render(); + + await user.click(await screen.findByRole("button", { name: "Choose credential for Provider connection" })); + await user.click(await screen.findByRole("menuitem", { name: /Replacement provider token/ })); + + await waitFor(() => expect(api.patchNodeFlowDraft).toHaveBeenCalledTimes(1)); + const patchInput = api.patchNodeFlowDraft.mock.calls[0]?.[1]; + expect(patchInput).toMatchObject({ projectId: "project-1", draftRevision: 2 }); + expect(patchInput.graph.nodes[0]).toMatchObject({ + data: { prompt: "Keep this configuration" }, + credentialBindings: [ + { slot: "audit", credentialId: "credential-audit" }, + { slot: "provider", credentialId: "credential-new" }, + ], + }); + expect(await screen.findByText("Credential binding saved and draft review refreshed.")).toBeInTheDocument(); + expect(api.fetchNodeFlow).toHaveBeenCalledWith("flow-1"); + expect(api.validateNodeFlowDraft).toHaveBeenCalledTimes(2); + expect(document.body).not.toHaveTextContent("hidden-key"); + }); + + it("rebinds and explicitly unbinds one slot without changing sibling bindings or node data", async () => { + const user = userEvent.setup(); + const initiallyBound = { + ...credentialFlow, + graph: { + ...credentialFlow.graph, + nodes: [{ + ...credentialFlow.graph.nodes[0]!, + credentialBindings: [ + { slot: "audit", credentialId: "credential-audit" }, + { slot: "provider", credentialId: "credential-old" }, + ], + }], + }, + }; + const state = setupCredentialFlow(initiallyBound); + api.patchNodeFlowDraft.mockImplementation(async (_flowId: string, input: { graph: typeof credentialFlow.graph }) => { + const saved = state.updateFromPatch(input); + return { draft: credentialReview(saved) }; + }); + render(); + + await user.click(await screen.findByRole("button", { name: "Choose credential for Provider connection" })); + await user.click(await screen.findByRole("menuitem", { name: /Replacement provider token/ })); + expect(await screen.findByText("Credential binding saved and draft review refreshed.")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Choose credential for Provider connection" })); + await user.click(await screen.findByRole("menuitem", { name: /Remove Replacement provider token binding/ })); + + expect(await screen.findByText("Credential binding removed and draft review refreshed.")).toBeInTheDocument(); + const unbindInput = api.patchNodeFlowDraft.mock.calls[1]?.[1]; + expect(unbindInput).toMatchObject({ draftRevision: 3 }); + expect(unbindInput.graph.nodes[0]).toMatchObject({ + data: { prompt: "Keep this configuration" }, + credentialBindings: [{ slot: "audit", credentialId: "credential-audit" }], + }); + }); + + it("refreshes a conflicted draft, keeps the slot picker open, and requires an explicit retry", async () => { + const user = userEvent.setup(); + const state = setupCredentialFlow(); + const latest = { + ...credentialFlow, + version: 3, + graph: { + ...credentialFlow.graph, + nodes: [{ ...credentialFlow.graph.nodes[0]!, data: { prompt: "Sibling edit from latest draft" } }], + }, + }; + api.patchNodeFlowDraft.mockResolvedValueOnce({ + conflict: { code: "draft_revision_conflict", flowId: "flow-1", expectedDraftRevision: 2, actualDraftRevision: 3, message: "The draft changed after it was read; reload the summary and reapply the patch." }, + }).mockImplementationOnce(async (_flowId: string, input: { graph: typeof credentialFlow.graph }) => { + const saved = state.updateFromPatch(input); + return { draft: credentialReview(saved) }; + }); + let fetchCount = 0; + api.fetchNodeFlow.mockImplementation(async () => { + fetchCount += 1; + if (fetchCount === 1) { state.replace(latest); return latest; } + return state.current(); + }); + render(); + + await user.click(await screen.findByRole("button", { name: "Choose credential for Provider connection" })); + await user.click(await screen.findByRole("menuitem", { name: /Replacement provider token/ })); + + expect(await screen.findByRole("alert")).toHaveTextContent("choose the credential again to retry"); + expect(api.patchNodeFlowDraft).toHaveBeenCalledTimes(1); + expect(screen.getByRole("menu", { name: "Credential picker for Provider connection" })).toBeInTheDocument(); + + await user.click(screen.getByRole("menuitem", { name: /Replacement provider token/ })); + expect(await screen.findByText("Credential binding saved and draft review refreshed.")).toBeInTheDocument(); + expect(api.patchNodeFlowDraft.mock.calls[1]?.[1]).toMatchObject({ draftRevision: 3 }); + expect(api.patchNodeFlowDraft.mock.calls[1]?.[1].graph.nodes[0].data).toEqual({ prompt: "Sibling edit from latest draft" }); + }); + + it("announces policy denial without presenting the requested status as saved", async () => { + const user = userEvent.setup(); + setupCredentialFlow(); + api.patchNodeFlowDraft.mockRejectedValueOnce(new Error("Policy denied this project change")); + render(); + + await user.click(await screen.findByRole("button", { name: "Choose credential for Provider connection" })); + await user.click(await screen.findByRole("menuitem", { name: /Replacement provider token/ })); + + expect(await screen.findByRole("alert")).toHaveTextContent("policy denied the change"); + expect(screen.queryByText("Request binding")).not.toBeInTheDocument(); + expect(screen.queryByText("Credential binding saved and draft review refreshed.")).not.toBeInTheDocument(); + }); }); From 0f2053049a68140c6b684d0289c93d46b0a5cf6b Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 23:03:41 +0000 Subject: [PATCH 14/22] fix(task T05): address qa review via codex --- .../custom-dashboard-foundation.md | 2 +- ...chitecture-custom-dashboard-foundation.mdx | 2 +- .../custom-dashboard-foundation.md | 2 +- .../custom-dashboard-validation-utils.ts | 23 ++++++++++------ .../custom-dashboard-validation-utils.test.ts | 27 +++++++++++++++++-- 5 files changed, 43 insertions(+), 13 deletions(-) diff --git a/docs-web/architecture/custom-dashboard-foundation.md b/docs-web/architecture/custom-dashboard-foundation.md index 6b309b8a82..ae43447fa4 100644 --- a/docs-web/architecture/custom-dashboard-foundation.md +++ b/docs-web/architecture/custom-dashboard-foundation.md @@ -101,6 +101,6 @@ Navigation is centralized through `dashboard/src/v2/lib/navigation-items.ts`, so ## Docker and Logs -Docker argument construction lives in `src/services/custom-dashboard-docker-plan.ts`. Validation containers use the configured CLI workflow image, bind-mount only the generated workspace/runtime home plus an optional setup script, and do not mount provider credential directories. The binding service never calls secret resolution, and credential values and binding IDs are not written to generated files, bridges, validation reports/logs, Docker arguments, iframe configuration, or browser messages. +Docker argument construction lives in `src/services/custom-dashboard-docker-plan.ts`. Validation containers use the configured CLI workflow image, bind-mount only the generated workspace/runtime home plus an optional setup script, and do not mount provider credential directories. The binding service never calls secret resolution, and credential values and binding IDs are not written to generated files, bridges, validation reports/logs, Docker arguments, iframe configuration, or browser messages. Before bridge configuration is materialized, credential-related properties are removed and known binding IDs are recursively replaced even when they are embedded within larger runtime-metadata or source-node strings. Logs are captured in the validation runtime directory and combined with bounded `docker logs` output through `getValidationLogs(sessionId, tail)`. `stopValidation` removes the detached container while preserving a passed revision report, and `removeValidation` removes the session row after container cleanup. diff --git a/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx b/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx index 6b309b8a82..ae43447fa4 100644 --- a/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx +++ b/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx @@ -101,6 +101,6 @@ Navigation is centralized through `dashboard/src/v2/lib/navigation-items.ts`, so ## Docker and Logs -Docker argument construction lives in `src/services/custom-dashboard-docker-plan.ts`. Validation containers use the configured CLI workflow image, bind-mount only the generated workspace/runtime home plus an optional setup script, and do not mount provider credential directories. The binding service never calls secret resolution, and credential values and binding IDs are not written to generated files, bridges, validation reports/logs, Docker arguments, iframe configuration, or browser messages. +Docker argument construction lives in `src/services/custom-dashboard-docker-plan.ts`. Validation containers use the configured CLI workflow image, bind-mount only the generated workspace/runtime home plus an optional setup script, and do not mount provider credential directories. The binding service never calls secret resolution, and credential values and binding IDs are not written to generated files, bridges, validation reports/logs, Docker arguments, iframe configuration, or browser messages. Before bridge configuration is materialized, credential-related properties are removed and known binding IDs are recursively replaced even when they are embedded within larger runtime-metadata or source-node strings. Logs are captured in the validation runtime directory and combined with bounded `docker logs` output through `getValidationLogs(sessionId, tail)`. `stopValidation` removes the detached container while preserving a passed revision report, and `removeValidation` removes the session row after container cleanup. diff --git a/docs/architecture/custom-dashboard-foundation.md b/docs/architecture/custom-dashboard-foundation.md index effd80145a..57aeb569b4 100644 --- a/docs/architecture/custom-dashboard-foundation.md +++ b/docs/architecture/custom-dashboard-foundation.md @@ -101,6 +101,6 @@ Navigation is centralized through `dashboard/src/v2/lib/navigation-items.ts`, so ## Docker and Logs -Docker argument construction lives in `src/services/custom-dashboard-docker-plan.ts`. Validation containers use the configured CLI workflow image, bind-mount only the generated workspace/runtime home plus an optional setup script, and do not mount provider credential directories. The binding service never calls secret resolution, and credential values and binding IDs are not written to generated files, bridges, validation reports/logs, Docker arguments, iframe configuration, or browser messages. +Docker argument construction lives in `src/services/custom-dashboard-docker-plan.ts`. Validation containers use the configured CLI workflow image, bind-mount only the generated workspace/runtime home plus an optional setup script, and do not mount provider credential directories. The binding service never calls secret resolution, and credential values and binding IDs are not written to generated files, bridges, validation reports/logs, Docker arguments, iframe configuration, or browser messages. Before bridge configuration is materialized, credential-related properties are removed and known binding IDs are recursively replaced even when they are embedded within larger runtime-metadata or source-node strings. Logs are captured in the validation runtime directory and combined with bounded `docker logs` output through `getValidationLogs(sessionId, tail)`. `stopValidation` removes the detached container while preserving a passed revision report, and `removeValidation` removes the session row after container cleanup. diff --git a/src/services/custom-dashboard-validation-utils.ts b/src/services/custom-dashboard-validation-utils.ts index 660e7919ed..2049f6504e 100644 --- a/src/services/custom-dashboard-validation-utils.ts +++ b/src/services/custom-dashboard-validation-utils.ts @@ -9,6 +9,7 @@ import { isPathInside } from "../utils/path-validator.js"; export const CUSTOM_DASHBOARD_VALIDATION_LOG_TAIL_LINES = 200; export const CUSTOM_DASHBOARD_VALIDATION_MAX_LOG_TAIL_LINES = 1000; +const CREDENTIAL_BINDING_ID_REDACTION = "[REDACTED_CREDENTIAL_BINDING_ID]"; export interface CustomDashboardBridgeConfig { projectId: string; @@ -218,23 +219,28 @@ export function buildBridgeConfig(revision: CustomDashboardRevisionRecord): Cust function assertBundleOmitsCredentialBindingIds(revision: CustomDashboardRevisionRecord): void { const bindingIds = credentialBindingIds(revision); - if (bindingIds.size === 0) return; - if (revision.fileBundle.files.some((file) => [...bindingIds].some((credentialId) => file.content.includes(credentialId)))) { + if (bindingIds.length === 0) return; + if (revision.fileBundle.files.some((file) => bindingIds.some((credentialId) => file.content.includes(credentialId)))) { throw new Error("Custom dashboard file bundles cannot contain credential binding identifiers."); } } -function credentialBindingIds(revision: CustomDashboardRevisionRecord): Set { - return new Set((revision.credentialBindings ?? []).map((binding) => binding.credentialId)); +function credentialBindingIds(revision: CustomDashboardRevisionRecord): string[] { + return [...new Set((revision.credentialBindings ?? []).map((binding) => binding.credentialId))] + .filter((credentialId) => credentialId.length > 0) + .sort((left, right) => right.length - left.length); } -function sanitizeBridgeValue(value: T, excludedIdentifiers: ReadonlySet): T { +function sanitizeBridgeValue(value: T, excludedIdentifiers: readonly string[]): T { return sanitizeBridgeUnknown(value, excludedIdentifiers) as T; } -function sanitizeBridgeUnknown(value: unknown, excludedIdentifiers: ReadonlySet): unknown { +function sanitizeBridgeUnknown(value: unknown, excludedIdentifiers: readonly string[]): unknown { if (typeof value === "string") { - return excludedIdentifiers.has(value) ? undefined : value; + return excludedIdentifiers.reduce( + (safe, credentialId) => safe.split(credentialId).join(CREDENTIAL_BINDING_ID_REDACTION), + value, + ); } if (Array.isArray(value)) { return value @@ -248,7 +254,8 @@ function sanitizeBridgeUnknown(value: unknown, excludedIdentifiers: ReadonlySet< const normalizedKey = key.toLowerCase(); if (normalizedKey === "credentialbindings" || normalizedKey === "credentialbindingrevision" - || normalizedKey === "credentialid") { + || normalizedKey === "credentialid" + || excludedIdentifiers.some((credentialId) => key.includes(credentialId))) { continue; } const sanitized = sanitizeBridgeUnknown(entry, excludedIdentifiers); diff --git a/tests/backend/services/custom-dashboard-validation-utils.test.ts b/tests/backend/services/custom-dashboard-validation-utils.test.ts index bfe28fe91c..d00ea91518 100644 --- a/tests/backend/services/custom-dashboard-validation-utils.test.ts +++ b/tests/backend/services/custom-dashboard-validation-utils.test.ts @@ -103,9 +103,22 @@ describe("custom dashboard validation filesystem utilities", () => { }], }, credentialBindings: [{ slotId: "metrics_api", credentialId }], + sourceNodeGraph: { + nodes: [{ + id: "metrics", + type: "external_api", + title: "Metrics", + config: { endpoint: `https://metrics.invalid/credentials/${credentialId}/summary` }, + }], + edges: [], + }, runtimeMetadata: { credentialBindings: [{ slotId: "metrics_api", credentialId }], - nested: { credentialId }, + [`diagnostic-${credentialId}`]: "must be removed with its binding-bearing key", + nested: { + credentialId, + diagnostic: `binding=${credentialId};state=configured`, + }, }, }); const bridgeConfig = buildBridgeConfig(boundRevision); @@ -116,7 +129,17 @@ describe("custom dashboard validation filesystem utilities", () => { bridgeConfig, }); - expect(JSON.stringify(bridgeConfig)).not.toContain(credentialId); + const serializedBridgeConfig = JSON.stringify(bridgeConfig); + expect(serializedBridgeConfig).not.toContain(credentialId); + expect(serializedBridgeConfig).not.toContain('"credentialBindings"'); + expect(serializedBridgeConfig).not.toContain('"credentialId"'); + expect(serializedBridgeConfig).toContain("[REDACTED_CREDENTIAL_BINDING_ID]"); + const materializedBridge = await fs.readFile( + path.join(workspacePath, ".codeux-harness", "codeux-data-bridge.ts"), + "utf8", + ); + expect(materializedBridge).not.toContain(credentialId); + expect(materializedBridge).toContain("[REDACTED_CREDENTIAL_BINDING_ID]"); expect(await readDirectoryText(workspacePath)).not.toContain(credentialId); }); From 5cb888f884da603520deed664719e2a3a9e79ba8 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 23:29:27 +0000 Subject: [PATCH 15/22] feat(task T08): implement via codex --- dashboard/src/v2/CustomDashboardsPage.tsx | 202 +++++++++- .../CustomDashboardCredentialSlotsPanel.tsx | 309 +++++++++++++++ .../CustomDashboardEditorPanel.tsx | 28 +- .../__tests__/CustomDashboardsPage.test.tsx | 372 +++++++++++++++++- .../src/v2/lib/automation-credential-api.ts | 4 +- dashboard/src/v2/lib/custom-dashboard-api.ts | 109 +++++ .../docs/user-dashboard-custom-dashboards.mdx | 4 +- docs-web/user/dashboard/custom-dashboards.md | 4 +- docs/dashboard/custom-dashboards.md | 6 +- 9 files changed, 1013 insertions(+), 25 deletions(-) create mode 100644 dashboard/src/v2/components/custom-dashboards/CustomDashboardCredentialSlotsPanel.tsx diff --git a/dashboard/src/v2/CustomDashboardsPage.tsx b/dashboard/src/v2/CustomDashboardsPage.tsx index 46be5ce00d..ac3587fe60 100644 --- a/dashboard/src/v2/CustomDashboardsPage.tsx +++ b/dashboard/src/v2/CustomDashboardsPage.tsx @@ -1,5 +1,5 @@ import type { FunctionComponent } from "preact"; -import { useCallback, useEffect, useMemo, useState } from "preact/hooks"; +import { useCallback, useEffect, useMemo, useRef, useState } from "preact/hooks"; import { AlertTriangle, ExternalLink, LayoutDashboard, RefreshCw, Save } from "lucide-preact"; import { PageContainer } from "./components/layout/PageContainer.js"; import { PageHeader } from "./components/layout/PageHeader.js"; @@ -12,18 +12,27 @@ import { useActionFeedback } from "./hooks/use-action-feedback.js"; import { useProjectData } from "./context/project-data.js"; import { archiveCustomDashboard, + bindCustomDashboardCredential, createCustomDashboard, createCustomDashboardRevision, fetchCustomDashboard, + fetchCustomDashboardCredentialBindings, fetchCustomDashboardDataCatalog, fetchCustomDashboardValidationLogs, fetchCustomDashboardValidationSession, fetchCustomDashboards, publishCustomDashboardRevision, startCustomDashboardValidation, + unbindCustomDashboardCredential, updateCustomDashboardDraft, + CustomDashboardCredentialBindingApiError, + type CustomDashboardCredentialBindingReview, type CustomDashboardDataCatalogResponse, } from "./lib/custom-dashboard-api.js"; +import { + fetchAutomationCredentials, + fetchCredentialHealth, +} from "./lib/automation-credential-api.js"; import { createDefaultCustomDashboardDraft, hasDraftChanged, @@ -38,7 +47,12 @@ import { type CustomDashboardEditorTab, } from "./components/custom-dashboards/CustomDashboardEditorPanel.js"; import { CustomDashboardValidationPanel } from "./components/custom-dashboards/CustomDashboardValidationPanel.js"; +import { CustomDashboardCredentialSlotsPanel } from "./components/custom-dashboards/CustomDashboardCredentialSlotsPanel.js"; import { CustomDashboardViewer } from "./components/custom-dashboards/CustomDashboardViewer.js"; +import type { + AutomationCredentialMetadata, + CredentialBackendHealth, +} from "../../../src/contracts/automation-credential-types.js"; import type { CreateCustomDashboardRevisionInput, CustomDashboardDataSourceNodeGraph, @@ -88,6 +102,14 @@ export const CustomDashboardsPage: FunctionComponent = () => { const [revisions, setRevisions] = useState([]); const [selectedRevisionId, setSelectedRevisionId] = useState(null); const [catalog, setCatalog] = useState(null); + const [credentialReview, setCredentialReview] = useState(null); + const [credentialMetadata, setCredentialMetadata] = useState([]); + const [credentialHealth, setCredentialHealth] = useState(null); + const [credentialLoading, setCredentialLoading] = useState(false); + const [credentialLoadError, setCredentialLoadError] = useState(null); + const [savingCredentialSlotId, setSavingCredentialSlotId] = useState(null); + const [credentialSlotErrors, setCredentialSlotErrors] = useState>({}); + const [credentialSlotAnnouncements, setCredentialSlotAnnouncements] = useState>({}); const [draft, setDraft] = useState(null); const [activeTab, setActiveTab] = useState("manifest"); const [selectedFilePath, setSelectedFilePath] = useState("src/dashboard.tsx"); @@ -109,12 +131,54 @@ export const CustomDashboardsPage: FunctionComponent = () => { clearError, } = useActionFeedback(); const archiveConfirm = useConfirmDialog(); + const bindingActionControllerRef = useRef(null); + const credentialLoadControllerRef = useRef(null); const selectedRevision = useMemo( () => revisions.find((revision) => revision.id === selectedRevisionId) ?? null, [revisions, selectedRevisionId], ); const dirty = useMemo(() => draft ? hasDraftChanged(selectedDashboard, draft) : false, [draft, selectedDashboard]); + const hasCredentialSlots = Boolean(selectedDashboard?.manifest.credentialSlots?.length); + + const adoptCredentialReview = useCallback((review: CustomDashboardCredentialBindingReview): void => { + setCredentialReview(review); + setSelectedDashboard((current) => current?.id === review.dashboardId + ? { + ...current, + credentialBindings: review.slots.flatMap((slotReview) => slotReview.binding ? [slotReview.binding] : []), + credentialBindingRevision: review.credentialBindingRevision ?? current.credentialBindingRevision, + } + : current); + }, []); + + const loadCredentialState = useCallback(async ( + nextProjectId: string, + dashboardId: string, + signal?: AbortSignal, + ): Promise => { + setCredentialLoading(true); + setCredentialLoadError(null); + try { + const [review, credentials, health] = await Promise.all([ + fetchCustomDashboardCredentialBindings(nextProjectId, dashboardId, signal), + fetchAutomationCredentials(nextProjectId, signal), + fetchCredentialHealth(signal), + ]); + if (signal?.aborted) return null; + setCredentialMetadata(credentials); + setCredentialHealth(health); + adoptCredentialReview(review); + return review; + } catch (error) { + if (!signal?.aborted) { + setCredentialLoadError(error instanceof Error ? error.message : "Credential metadata could not be loaded."); + } + return null; + } finally { + if (!signal?.aborted) setCredentialLoading(false); + } + }, [adoptCredentialReview]); const loadProjectDashboards = useCallback(async (nextProjectId: string, signal?: AbortSignal): Promise => { setLoading(true); @@ -196,6 +260,31 @@ export const CustomDashboardsPage: FunctionComponent = () => { return () => controller.abort(); }, [loadDashboardDetail, selectedDashboardId]); + useEffect(() => { + bindingActionControllerRef.current?.abort(); + credentialLoadControllerRef.current?.abort(); + setSavingCredentialSlotId(null); + setCredentialSlotErrors({}); + setCredentialSlotAnnouncements({}); + if (!projectId || !selectedDashboardId || !hasCredentialSlots) { + setCredentialReview(null); + setCredentialMetadata([]); + setCredentialHealth(null); + setCredentialLoadError(null); + setCredentialLoading(false); + return; + } + const controller = new AbortController(); + credentialLoadControllerRef.current = controller; + void loadCredentialState(projectId, selectedDashboardId, controller.signal).finally(() => { + if (credentialLoadControllerRef.current === controller) credentialLoadControllerRef.current = null; + }); + return () => { + controller.abort(); + if (credentialLoadControllerRef.current === controller) credentialLoadControllerRef.current = null; + }; + }, [hasCredentialSlots, loadCredentialState, projectId, selectedDashboardId]); + const refreshSelectedDashboard = useCallback(async (): Promise => { if (!selectedDashboardId) { return; @@ -203,6 +292,100 @@ export const CustomDashboardsPage: FunctionComponent = () => { await loadDashboardDetail(selectedDashboardId); }, [loadDashboardDetail, selectedDashboardId]); + const refreshCredentialState = useCallback((): void => { + if (!projectId || !selectedDashboardId || !hasCredentialSlots) return; + credentialLoadControllerRef.current?.abort(); + const controller = new AbortController(); + credentialLoadControllerRef.current = controller; + void loadCredentialState(projectId, selectedDashboardId, controller.signal).finally(() => { + if (credentialLoadControllerRef.current === controller) credentialLoadControllerRef.current = null; + }); + }, [hasCredentialSlots, loadCredentialState, projectId, selectedDashboardId]); + + const completeCredentialMutation = useCallback(async ( + slotId: string, + announcement: string, + operation: (expectedBindingRevision: number, signal: AbortSignal) => Promise, + ): Promise => { + if (!projectId || !selectedDashboardId || credentialReview?.credentialBindingRevision === null || credentialReview?.credentialBindingRevision === undefined) { + setCredentialSlotErrors((current) => ({ ...current, [slotId]: "Refresh credential metadata before changing this binding." })); + return; + } + bindingActionControllerRef.current?.abort(); + credentialLoadControllerRef.current?.abort(); + credentialLoadControllerRef.current = null; + const controller = new AbortController(); + bindingActionControllerRef.current = controller; + const mutationProjectId = projectId; + const mutationDashboardId = selectedDashboardId; + setSavingCredentialSlotId(slotId); + setCredentialSlotErrors((current) => ({ ...current, [slotId]: "" })); + setCredentialSlotAnnouncements((current) => ({ ...current, [slotId]: "" })); + try { + const review = await operation(credentialReview.credentialBindingRevision, controller.signal); + if (controller.signal.aborted) return; + adoptCredentialReview(review); + setCredentialSlotAnnouncements((current) => ({ ...current, [slotId]: announcement })); + setValidationSession(null); + setLogs(""); + await loadDashboardDetail(mutationDashboardId, controller.signal); + if (!controller.signal.aborted) { + await loadCredentialState(mutationProjectId, mutationDashboardId, controller.signal); + } + } catch (error) { + if (controller.signal.aborted) return; + if (error instanceof CustomDashboardCredentialBindingApiError && error.status === 409) { + await loadDashboardDetail(mutationDashboardId, controller.signal); + if (!controller.signal.aborted) { + await loadCredentialState(mutationProjectId, mutationDashboardId, controller.signal); + setCredentialSlotErrors((current) => ({ + ...current, + [slotId]: "Bindings changed in another session. The dashboard was refreshed; review the current binding and explicitly retry your change.", + })); + } + } else { + const policyMessage = error instanceof CustomDashboardCredentialBindingApiError && error.issues.length > 0 + ? error.issues.map((issue) => issue.message).join(" ") + : error instanceof Error + ? error.message + : "The credential binding could not be changed."; + setCredentialSlotErrors((current) => ({ ...current, [slotId]: policyMessage })); + } + } finally { + if (bindingActionControllerRef.current === controller) { + bindingActionControllerRef.current = null; + setSavingCredentialSlotId(null); + } + } + }, [adoptCredentialReview, credentialReview?.credentialBindingRevision, loadCredentialState, loadDashboardDetail, projectId, selectedDashboardId]); + + const handleBindCredential = useCallback(async (slotId: string, credentialId: string): Promise => { + await completeCredentialMutation( + slotId, + "Credential binding saved. Validation and publication readiness were refreshed.", + (expectedBindingRevision, signal) => bindCustomDashboardCredential( + projectId ?? "", + selectedDashboardId ?? "", + { slotId, credentialId, expectedBindingRevision }, + signal, + ), + ); + }, [completeCredentialMutation, projectId, selectedDashboardId]); + + const handleUnbindCredential = useCallback(async (slotId: string): Promise => { + await completeCredentialMutation( + slotId, + "Credential unbound. Validation and publication readiness were refreshed.", + (expectedBindingRevision, signal) => unbindCustomDashboardCredential( + projectId ?? "", + selectedDashboardId ?? "", + slotId, + expectedBindingRevision, + signal, + ), + ); + }, [completeCredentialMutation, projectId, selectedDashboardId]); + const buildDraftInput = useCallback((): UpdateCustomDashboardDraftInput & CreateCustomDashboardRevisionInput => { if (!draft) { throw new Error("No dashboard draft is selected."); @@ -520,6 +703,23 @@ export const CustomDashboardsPage: FunctionComponent = () => { selectedFilePath={selectedFilePath} onSelectedFilePathChange={setSelectedFilePath} catalog={catalog} + credentialPanel={hasCredentialSlots ? ( + + ) : undefined} /> ; + slotAnnouncements: Record; + onBind: (slotId: string, credentialId: string) => Promise; + onUnbind: (slotId: string) => Promise; + onRefresh: () => void; +} + +const backendReady = (health: CredentialBackendHealth | null): boolean => Boolean( + health?.available + && health.secure + && health.keyId + && health.keyVersion !== null, +); + +const projectCanUse = (credential: AutomationCredentialMetadata, projectId: string): boolean => ( + credential.scope === "project" + ? credential.projectId === projectId + : credential.allowedProjectIds.includes(projectId) +); + +const eligibleCredentials = ( + projectId: string, + credentials: AutomationCredentialMetadata[], + slotReview: CustomDashboardCredentialSlotReview, + health: CredentialBackendHealth | null, +): AutomationCredentialMetadata[] => { + if (!backendReady(health)) return []; + const compatibleIds = new Set( + (slotReview.candidates ?? []) + .filter((candidate) => candidate.compatible) + .map((candidate) => candidate.credentialId), + ); + return credentials.filter((credential) => ( + compatibleIds.has(credential.id) + && credential.status === "active" + && credential.configured + && projectCanUse(credential, projectId) + && slotReview.slot.allowedKinds.includes(credential.kind) + && slotReview.slot.requiredCapabilities.every((capability) => credential.capabilities.includes(capability)) + )); +}; + +const MetadataPill: FunctionComponent<{ children: string }> = ({ children }) => ( + + {children} + +); + +export const CustomDashboardCredentialSlotsPanel: FunctionComponent = ({ + projectId, + dashboardId, + review, + credentials, + health, + loading, + loadError, + savingSlotId, + slotErrors, + slotAnnouncements, + onBind, + onUnbind, + onRefresh, +}) => { + const [selectedCredentialBySlot, setSelectedCredentialBySlot] = useState>({}); + const actionRefs = useRef>({}); + + useEffect(() => { + setSelectedCredentialBySlot({}); + }, [projectId, dashboardId]); + + const reviewedSlots = review?.slots ?? []; + const optionsBySlot = useMemo(() => Object.fromEntries( + reviewedSlots.map((slotReview) => [ + slotReview.slot.slotId, + eligibleCredentials(projectId, credentials, slotReview, health), + ]), + ), [credentials, health, projectId, reviewedSlots]); + + const restoreActionFocus = (slotId: string): void => { + window.requestAnimationFrame(() => actionRefs.current[slotId]?.focus({ preventScroll: true })); + }; + + const bind = async (slotId: string): Promise => { + const credentialId = selectedCredentialBySlot[slotId]; + if (!credentialId) return; + try { + await onBind(slotId, credentialId); + } finally { + restoreActionFocus(slotId); + } + }; + + const unbind = async (slotId: string): Promise => { + try { + await onUnbind(slotId); + } finally { + restoreActionFocus(slotId); + } + }; + + const renderSettingsLink = () => ( + writeSettingsNavigationState({ activeCategory: "integrations", activeInvocationRoute: "task_coding", focusedSections: {} })} + > + + ); + + return ( +
+
+
+
+
+

+ Bind only project-visible metadata that satisfies each declaration. Secret values never enter this editor, generated files, or runtime text. +

+
+ +
+ +
+ {loading ?

Loading credential metadata and custody health…

: null} + {!loading && health && !backendReady(health) ? ( +
+
+
+
+ ) : null} + {!loading && loadError ? ( +
+

{loadError}

+ {renderSettingsLink()} +
+ ) : null} + {!loading && review ? ( +
+ {review.valid + ? "Credential declarations are ready to be included in the next revision." + : "Credential declarations need attention before the next revision is publication-ready."} +
+ ) : null} +
+ +
+ {reviewedSlots.map((slotReview) => { + const slot = slotReview.slot; + const options = optionsBySlot[slot.slotId] ?? []; + const current = slotReview.metadata; + const selectedCredentialId = selectedCredentialBySlot[slot.slotId] ?? ""; + const saving = savingSlotId === slot.slotId; + const hasReplacement = options.some((credential) => credential.id !== slotReview.binding?.credentialId); + const issueMessages = slotReview.issues.map((issue) => issue.message); + const error = slotErrors[slot.slotId]; + const announcement = slotAnnouncements[slot.slotId]; + return ( +
+
+
+

{slot.label}

+

Slot {slot.slotId}

+
+
+ {slot.phase === "build" ? "Build phase" : "Runtime phase"} + {slot.required ? "Required" : "Optional"} +
+
+ +
+
Allowed kinds
{slot.allowedKinds.join(", ")}
+
Required capabilities
{slot.requiredCapabilities.join(", ") || "None"}
+
+ +
+

Current credential metadata

+ {current ? ( +
+

{current.name}

+

+ {current.kind} · {current.status} · {current.configured ? "configured" : "not configured"} +

+

Capabilities: {current.capabilities.join(", ") || "none"}

+
+ ) : ( +

No credential is bound.

+ )} +
+ + {issueMessages.length > 0 ? ( +
+ {issueMessages.join(" ")} +
+ ) : null} + {error ?
{error}
: null} +

{saving ? `Saving ${slot.label} binding.` : announcement ?? ""}

+ +
+ + +
+ + {!loading && !hasReplacement ? ( +
+

No {slotReview.binding ? "other " : ""}active, configured, project-authorized credential matches this slot.

+ {renderSettingsLink()} +
+ ) : null} + + {slotReview.binding ? ( + + ) : null} + {slotReview.binding && slot.required ? ( +

+

+ ) : null} + {slotReview.compatible && slotReview.binding ? ( +

+

+ ) : null} +
+ ); + })} +
+
+ ); +}; diff --git a/dashboard/src/v2/components/custom-dashboards/CustomDashboardEditorPanel.tsx b/dashboard/src/v2/components/custom-dashboards/CustomDashboardEditorPanel.tsx index 6fb8aaf59a..9b446c8957 100644 --- a/dashboard/src/v2/components/custom-dashboards/CustomDashboardEditorPanel.tsx +++ b/dashboard/src/v2/components/custom-dashboards/CustomDashboardEditorPanel.tsx @@ -1,5 +1,5 @@ -import type { FunctionComponent } from "preact"; -import { Database, FileCode2, Layers3, Palette, ScrollText } from "lucide-preact"; +import type { ComponentChildren, FunctionComponent } from "preact"; +import { Database, FileCode2, KeyRound, Layers3, Palette, ScrollText } from "lucide-preact"; import { Button } from "../ui/Button.js"; import type { CustomDashboardDataSourceNodeGraph, @@ -11,7 +11,7 @@ import type { import type { CustomDashboardDataCatalogResponse, CustomDashboardCatalogSource } from "../../lib/custom-dashboard-api.js"; import { parseJsonDraft, stableJsonStringify } from "../../lib/custom-dashboard-view-models.js"; -export type CustomDashboardEditorTab = "manifest" | "files" | "sources" | "styleguide" | "catalog"; +export type CustomDashboardEditorTab = "manifest" | "files" | "sources" | "styleguide" | "catalog" | "credentials"; export interface CustomDashboardDraftState { title: string; @@ -30,6 +30,7 @@ interface CustomDashboardEditorPanelProps { selectedFilePath: string; onSelectedFilePathChange: (path: string) => void; catalog: CustomDashboardDataCatalogResponse | null; + credentialPanel?: ComponentChildren; } const tabs: Array<{ id: CustomDashboardEditorTab; label: string; icon: typeof ScrollText }> = [ @@ -48,10 +49,15 @@ export const CustomDashboardEditorPanel: FunctionComponent { const parsedBundle = parseJsonDraft(draft.fileBundleText, "File bundle"); const files = parsedBundle.ok && Array.isArray(parsedBundle.value.files) ? parsedBundle.value.files : []; const selectedFile = files.find((file) => file.path === selectedFilePath) ?? files[0] ?? null; + const visibleTabs = credentialPanel + ? [...tabs, { id: "credentials" as const, label: "Credentials", icon: KeyRound }] + : tabs; + const effectiveActiveTab = activeTab === "credentials" && !credentialPanel ? "manifest" : activeTab; const setDraftField = (field: keyof CustomDashboardDraftState, value: string) => { onDraftChange({ ...draft, [field]: value }); @@ -134,9 +140,9 @@ export const CustomDashboardEditorPanel: FunctionComponent
- {tabs.map((tab) => { + {visibleTabs.map((tab) => { const Icon = tab.icon; - const selected = activeTab === tab.id; + const selected = effectiveActiveTab === tab.id; return (
); diff --git a/dashboard/src/v2/components/custom-dashboards/__tests__/CustomDashboardsPage.test.tsx b/dashboard/src/v2/components/custom-dashboards/__tests__/CustomDashboardsPage.test.tsx index 31bafe1188..69a0c90621 100644 --- a/dashboard/src/v2/components/custom-dashboards/__tests__/CustomDashboardsPage.test.tsx +++ b/dashboard/src/v2/components/custom-dashboards/__tests__/CustomDashboardsPage.test.tsx @@ -6,6 +6,10 @@ import "@testing-library/jest-dom/vitest"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { CustomDashboardsPage } from "../../../CustomDashboardsPage.js"; import { ProjectDataContext } from "../../../context/project-data.js"; +import type { + AutomationCredentialMetadata, + CredentialBackendHealth, +} from "../../../../../../src/contracts/automation-credential-types.js"; import type { CustomDashboardRecord, CustomDashboardRevisionRecord, @@ -13,30 +17,53 @@ import type { } from "../../../types.js"; import { archiveCustomDashboard, + bindCustomDashboardCredential, createCustomDashboard, createCustomDashboardRevision, fetchCustomDashboard, + fetchCustomDashboardCredentialBindings, fetchCustomDashboardDataCatalog, fetchCustomDashboardValidationLogs, fetchCustomDashboardValidationSession, fetchCustomDashboards, publishCustomDashboardRevision, startCustomDashboardValidation, + unbindCustomDashboardCredential, updateCustomDashboardDraft, + CustomDashboardCredentialBindingApiError, + type CustomDashboardCredentialBindingReview, } from "../../../lib/custom-dashboard-api.js"; +import { + fetchAutomationCredentials, + fetchCredentialHealth, +} from "../../../lib/automation-credential-api.js"; vi.mock("../../../lib/custom-dashboard-api.js", () => ({ archiveCustomDashboard: vi.fn(), + bindCustomDashboardCredential: vi.fn(), createCustomDashboard: vi.fn(), createCustomDashboardRevision: vi.fn(), fetchCustomDashboard: vi.fn(), + fetchCustomDashboardCredentialBindings: vi.fn(), fetchCustomDashboardDataCatalog: vi.fn(), fetchCustomDashboardValidationLogs: vi.fn(), fetchCustomDashboardValidationSession: vi.fn(), fetchCustomDashboards: vi.fn(), publishCustomDashboardRevision: vi.fn(), startCustomDashboardValidation: vi.fn(), + unbindCustomDashboardCredential: vi.fn(), updateCustomDashboardDraft: vi.fn(), + CustomDashboardCredentialBindingApiError: class CustomDashboardCredentialBindingApiError extends Error { + constructor(readonly status: number, message: string, readonly issues: unknown[] = []) { + super(message); + this.name = "CustomDashboardCredentialBindingApiError"; + } + }, +})); + +vi.mock("../../../lib/automation-credential-api.js", () => ({ + fetchAutomationCredentials: vi.fn(), + fetchCredentialHealth: vi.fn(), })); vi.mock("../../../lib/motion/index.js", () => ({ @@ -104,15 +131,6 @@ vi.mock("../CustomDashboardList.js", async () => { }; }); -vi.mock("../CustomDashboardEditorPanel.js", async () => { - const { h: createElement } = await vi.importActual("preact"); - return { - CustomDashboardEditorPanel: () => { - return createElement("section", { "aria-label": "Custom dashboard editor" }, "Editor"); - }, - }; -}); - vi.mock("../CustomDashboardValidationPanel.js", async () => { const { h: createElement } = await vi.importActual("preact"); return { @@ -156,6 +174,125 @@ const dashboard: CustomDashboardRecord = { updatedAt: "2026-07-07T00:00:00.000Z", }; +const dashboardWithSlots: CustomDashboardRecord = { + ...dashboard, + manifest: { + ...dashboard.manifest, + credentialSlots: [ + { + slotId: "deploy_api", + label: "Deployment API", + phase: "runtime", + required: true, + allowedKinds: ["api_token"], + requiredCapabilities: ["read", "write"], + }, + { + slotId: "build_registry", + label: "Build registry", + phase: "build", + required: false, + allowedKinds: ["registry_token"], + requiredCapabilities: ["read"], + }, + ], + }, +}; + +const credential = ( + id: string, + name: string, + kind: string, + capabilities: string[], + patch: Partial = {}, +): AutomationCredentialMetadata => ({ + id, + name, + kind, + scope: "project", + projectId: "project-1", + managementProjectId: "project-1", + allowedProjectIds: [], + capabilities, + status: "active", + configured: true, + keyId: "local-key", + keyVersion: 1, + version: 1, + lastValidatedAt: null, + validationStatus: "valid", + createdAt: "2026-07-07T00:00:00.000Z", + updatedAt: "2026-07-07T00:00:00.000Z", + ...patch, +}); + +const compatibleCredential = credential("credential-compatible", "Compatible Key", "api_token", ["read", "write"]); +const replacementCredential = credential("credential-replacement", "Replacement Key", "api_token", ["read", "write", "admin"]); +const wrongKindCredential = credential("credential-wrong-kind", "Wrong Kind", "password", ["read", "write"]); +const missingCapabilityCredential = credential("credential-read-only", "Read Only", "api_token", ["read"]); +const inactiveCredential = credential("credential-inactive", "Inactive Key", "api_token", ["read", "write"], { status: "revoked" }); + +const readyHealth: CredentialBackendHealth = { + available: true, + secure: true, + provider: "local", + keyId: "local-key", + keyVersion: 1, +}; + +const slotReview = ( + bindingCredential: AutomationCredentialMetadata | null = null, + revisionNumber = 1, +): CustomDashboardCredentialBindingReview => { + const candidates = [ + { credential: compatibleCredential, compatible: true, issues: [] as const, missingCapabilities: [] }, + { credential: replacementCredential, compatible: true, issues: [] as const, missingCapabilities: [] }, + { credential: wrongKindCredential, compatible: false, issues: ["kind_not_allowed"] as const, missingCapabilities: [] }, + { credential: missingCapabilityCredential, compatible: false, issues: ["capability_missing"] as const, missingCapabilities: ["write"] }, + { credential: inactiveCredential, compatible: false, issues: ["not_active"] as const, missingCapabilities: [] }, + ].map(({ credential: candidate, compatible, issues, missingCapabilities }) => ({ + credentialId: candidate.id, + metadata: candidate, + compatible, + issues: [...issues], + missingCapabilities, + })); + const requiredIssue = bindingCredential ? [] : [{ + field: "credentialBindings.deploy_api", + code: "required_binding_missing", + message: "Deployment API requires a credential binding.", + }]; + return { + projectId: "project-1", + dashboardId: "dashboard-1", + revisionId: null, + credentialBindingRevision: revisionNumber, + backend: readyHealth, + valid: requiredIssue.length === 0, + issues: requiredIssue, + slots: [ + { + slot: dashboardWithSlots.manifest.credentialSlots![0]!, + binding: bindingCredential ? { slotId: "deploy_api", credentialId: bindingCredential.id } : null, + metadata: bindingCredential, + compatible: Boolean(bindingCredential), + issues: requiredIssue, + candidates, + }, + { + slot: dashboardWithSlots.manifest.credentialSlots![1]!, + binding: null, + metadata: null, + compatible: true, + issues: [], + candidates: [], + }, + ], + credentialCandidateCount: candidates.length, + credentialCandidatesTruncated: false, + }; +}; + const revision: CustomDashboardRevisionRecord = { id: "revision-1", dashboardId: "dashboard-1", @@ -206,12 +343,27 @@ const renderPage = (context: typeof projectContext | any = projectContext) => re , ); +const openCredentialPanel = async () => { + const tab = await screen.findByRole("tab", { name: "Credentials" }); + fireEvent.click(tab); + return await screen.findByRole("region", { name: "Dashboard credential slots" }); +}; + describe("CustomDashboardsPage", () => { beforeEach(() => { cleanup(); vi.clearAllMocks(); vi.mocked(fetchCustomDashboards).mockResolvedValue({ dashboards: [dashboard] }); vi.mocked(fetchCustomDashboard).mockResolvedValue({ dashboard, revisions: [revision] }); + vi.mocked(fetchCustomDashboardCredentialBindings).mockResolvedValue(slotReview()); + vi.mocked(fetchAutomationCredentials).mockResolvedValue([ + compatibleCredential, + replacementCredential, + wrongKindCredential, + missingCapabilityCredential, + inactiveCredential, + ]); + vi.mocked(fetchCredentialHealth).mockResolvedValue(readyHealth); vi.mocked(fetchCustomDashboardDataCatalog).mockResolvedValue({ projectId: "project-1", dashboards: [], @@ -239,6 +391,7 @@ describe("CustomDashboardsPage", () => { expect(await screen.findByText("Dashboard Workspace")).toBeInTheDocument(); expect(await screen.findByText("Delivery Pulse")).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: "Credentials" })).not.toBeInTheDocument(); const publishButton = screen.getByRole("button", { name: /^Publish$/i }); expect(publishButton).toBeDisabled(); @@ -262,4 +415,205 @@ describe("CustomDashboardsPage", () => { expect(publishCustomDashboardRevision).toHaveBeenCalledWith("dashboard-1", "revision-1", "session-1"); }); }); + + it("keeps legacy dashboards without declared slots unchanged", async () => { + renderPage(); + + expect(await screen.findByText("Delivery Pulse")).toBeInTheDocument(); + expect(screen.queryByRole("region", { name: "Dashboard credential slots" })).not.toBeInTheDocument(); + expect(fetchCustomDashboardCredentialBindings).not.toHaveBeenCalled(); + expect(fetchAutomationCredentials).not.toHaveBeenCalled(); + expect(fetchCredentialHealth).not.toHaveBeenCalled(); + }); + + it("renders required and optional slot declarations and filters credential choices by policy", async () => { + vi.mocked(fetchCustomDashboard).mockResolvedValue({ dashboard: dashboardWithSlots, revisions: [revision] }); + renderPage(); + + const panel = await openCredentialPanel(); + await screen.findByText("Deployment API"); + expect(panel).toHaveTextContent("Deployment API"); + expect(panel).toHaveTextContent("Runtime phase"); + expect(panel).toHaveTextContent("Required"); + expect(panel).toHaveTextContent("api_token"); + expect(panel).toHaveTextContent("read, write"); + expect(panel).toHaveTextContent("Build registry"); + expect(panel).toHaveTextContent("Build phase"); + expect(panel).toHaveTextContent("Optional"); + expect(panel).toHaveTextContent("need attention before the next revision is publication-ready"); + + const select = screen.getByRole("combobox", { name: "Compatible credential for Deployment API" }); + expect(select).toHaveTextContent("Compatible Key"); + expect(select).toHaveTextContent("Replacement Key"); + expect(select).not.toHaveTextContent("Wrong Kind"); + expect(select).not.toHaveTextContent("Read Only"); + expect(select).not.toHaveTextContent("Inactive Key"); + }); + + it("binds, replaces, and unbinds with the latest revision while restoring action focus", async () => { + let currentReview = slotReview(); + vi.mocked(fetchCustomDashboard).mockResolvedValue({ dashboard: dashboardWithSlots, revisions: [revision] }); + vi.mocked(fetchCustomDashboardCredentialBindings).mockImplementation(async () => currentReview); + vi.mocked(bindCustomDashboardCredential).mockImplementation(async (_projectId, _dashboardId, input) => { + currentReview = slotReview(input.credentialId === replacementCredential.id ? replacementCredential : compatibleCredential, input.expectedBindingRevision + 1); + return currentReview; + }); + vi.mocked(unbindCustomDashboardCredential).mockImplementation(async (_projectId, _dashboardId, _slotId, expectedBindingRevision) => { + currentReview = slotReview(null, expectedBindingRevision + 1); + return currentReview; + }); + renderPage(); + + await openCredentialPanel(); + const select = await screen.findByRole("combobox", { name: "Compatible credential for Deployment API" }); + select.focus(); + fireEvent.keyDown(select, { key: "ArrowDown" }); + expect(document.activeElement).toBe(select); + fireEvent.input(select, { target: { value: compatibleCredential.id } }); + expect(select).toHaveValue(compatibleCredential.id); + const bindButton = screen.getByRole("button", { name: "Bind credential for Deployment API" }); + await waitFor(() => expect(bindButton).toBeEnabled()); + bindButton.focus(); + fireEvent.click(bindButton); + await waitFor(() => expect(bindCustomDashboardCredential).toHaveBeenCalledWith( + "project-1", + "dashboard-1", + { slotId: "deploy_api", credentialId: compatibleCredential.id, expectedBindingRevision: 1 }, + expect.any(AbortSignal), + )); + await screen.findByText("Compatible binding"); + await waitFor(() => expect(document.activeElement).toBe(screen.getByRole("button", { name: "Replace binding for Deployment API" }))); + + fireEvent.input(select, { target: { value: replacementCredential.id } }); + await waitFor(() => expect(screen.getByRole("button", { name: "Replace binding for Deployment API" })).toBeEnabled()); + fireEvent.click(screen.getByRole("button", { name: "Replace binding for Deployment API" })); + await waitFor(() => expect(bindCustomDashboardCredential).toHaveBeenLastCalledWith( + "project-1", + "dashboard-1", + { slotId: "deploy_api", credentialId: replacementCredential.id, expectedBindingRevision: 2 }, + expect.any(AbortSignal), + )); + expect(await screen.findByText("Replacement Key")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Unbind credential for Deployment API" })); + await waitFor(() => expect(unbindCustomDashboardCredential).toHaveBeenCalledWith( + "project-1", + "dashboard-1", + "deploy_api", + 3, + expect.any(AbortSignal), + )); + expect(await screen.findByText("Deployment API requires a credential binding.")).toBeInTheDocument(); + expect(screen.getByText(/need attention before the next revision is publication-ready/i)).toBeInTheDocument(); + }); + + it("explains unavailable custody and links to credential Settings", async () => { + const unavailableHealth: CredentialBackendHealth = { + available: false, + secure: false, + provider: "unavailable", + keyId: null, + keyVersion: null, + reason: "Local key custody is offline.", + }; + vi.mocked(fetchCustomDashboard).mockResolvedValue({ dashboard: dashboardWithSlots, revisions: [revision] }); + vi.mocked(fetchCredentialHealth).mockResolvedValue(unavailableHealth); + vi.mocked(fetchCustomDashboardCredentialBindings).mockResolvedValue({ ...slotReview(), backend: unavailableHealth }); + renderPage(); + + await openCredentialPanel(); + expect(await screen.findByText("Secure credential custody is unavailable.")).toBeInTheDocument(); + expect(screen.getByText("Local key custody is offline.")).toBeInTheDocument(); + expect(screen.getAllByRole("link", { name: "Manage credentials in Settings" })[0]).toHaveAttribute("href", "/config"); + expect(screen.getByRole("combobox", { name: "Compatible credential for Deployment API" })).toBeDisabled(); + }); + + it("cancels stale credential metadata requests when the selected dashboard changes", async () => { + const secondDashboard = { ...dashboardWithSlots, id: "dashboard-2", title: "Second Dashboard" }; + const signals: AbortSignal[] = []; + vi.mocked(fetchCustomDashboards).mockResolvedValue({ dashboards: [dashboardWithSlots, secondDashboard] }); + vi.mocked(fetchCustomDashboard).mockImplementation(async (dashboardId) => ({ + dashboard: dashboardId === secondDashboard.id ? secondDashboard : dashboardWithSlots, + revisions: [revision], + })); + vi.mocked(fetchCustomDashboardCredentialBindings).mockImplementation(async (_projectId, dashboardId, signal) => { + if (signal) signals.push(signal); + return { ...slotReview(), dashboardId }; + }); + renderPage(); + + await screen.findByRole("tab", { name: "Credentials" }); + fireEvent.click(screen.getByRole("button", { name: "Second Dashboard" })); + + await waitFor(() => expect(signals.length).toBeGreaterThanOrEqual(2)); + expect(signals[0]?.aborted).toBe(true); + expect(signals.at(-1)?.aborted).toBe(false); + }); + + it("refreshes on optimistic conflict and requires an explicit retry with the new revision", async () => { + let currentReview = slotReview(); + let attempts = 0; + vi.mocked(fetchCustomDashboard).mockResolvedValue({ dashboard: dashboardWithSlots, revisions: [revision] }); + vi.mocked(fetchCustomDashboardCredentialBindings).mockImplementation(async () => currentReview); + vi.mocked(bindCustomDashboardCredential).mockImplementation(async (_projectId, _dashboardId, input) => { + attempts += 1; + if (attempts === 1) { + currentReview = slotReview(replacementCredential, 2); + throw new CustomDashboardCredentialBindingApiError(409, "Bindings changed concurrently."); + } + currentReview = slotReview(compatibleCredential, input.expectedBindingRevision + 1); + return currentReview; + }); + renderPage(); + + await openCredentialPanel(); + const select = await screen.findByRole("combobox", { name: "Compatible credential for Deployment API" }); + fireEvent.input(select, { target: { value: compatibleCredential.id } }); + await waitFor(() => expect(screen.getByRole("button", { name: "Bind credential for Deployment API" })).toBeEnabled()); + fireEvent.click(screen.getByRole("button", { name: "Bind credential for Deployment API" })); + + expect(await screen.findByText(/dashboard was refreshed; review the current binding and explicitly retry/i)).toBeInTheDocument(); + expect(screen.getByText("Replacement Key")).toBeInTheDocument(); + expect(bindCustomDashboardCredential).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Replace binding for Deployment API" })); + await waitFor(() => expect(bindCustomDashboardCredential).toHaveBeenLastCalledWith( + "project-1", + "dashboard-1", + { slotId: "deploy_api", credentialId: compatibleCredential.id, expectedBindingRevision: 2 }, + expect.any(AbortSignal), + )); + }); + + it("surfaces policy denial on the affected slot and clears stale validation readiness after a save", async () => { + let currentReview = slotReview(); + let deny = true; + vi.mocked(fetchCustomDashboard).mockResolvedValue({ dashboard: dashboardWithSlots, revisions: [revision] }); + vi.mocked(fetchCustomDashboardCredentialBindings).mockImplementation(async () => currentReview); + vi.mocked(bindCustomDashboardCredential).mockImplementation(async (_projectId, _dashboardId, input) => { + if (deny) { + deny = false; + throw new CustomDashboardCredentialBindingApiError(403, "Credential capability policy denied this binding."); + } + currentReview = slotReview(compatibleCredential, input.expectedBindingRevision + 1); + return currentReview; + }); + renderPage(); + + await openCredentialPanel(); + fireEvent.click(await screen.findByRole("button", { name: "Validate" })); + await waitFor(() => expect(screen.getByRole("button", { name: "Publish" })).toBeEnabled()); + const select = screen.getByRole("combobox", { name: "Compatible credential for Deployment API" }); + fireEvent.input(select, { target: { value: compatibleCredential.id } }); + await waitFor(() => expect(screen.getByRole("button", { name: "Bind credential for Deployment API" })).toBeEnabled()); + fireEvent.click(screen.getByRole("button", { name: "Bind credential for Deployment API" })); + expect(await screen.findByText("Credential capability policy denied this binding.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Publish" })).toBeEnabled(); + + const detailRefreshesBeforeSave = vi.mocked(fetchCustomDashboard).mock.calls.length; + fireEvent.click(screen.getByRole("button", { name: "Bind credential for Deployment API" })); + await screen.findByText(/Credential binding saved. Validation and publication readiness were refreshed./i); + expect(screen.getByRole("button", { name: "Publish" })).toBeDisabled(); + expect(vi.mocked(fetchCustomDashboard).mock.calls.length).toBeGreaterThan(detailRefreshesBeforeSave); + }); }); diff --git a/dashboard/src/v2/lib/automation-credential-api.ts b/dashboard/src/v2/lib/automation-credential-api.ts index cf32a2b0f0..c3dc56717a 100644 --- a/dashboard/src/v2/lib/automation-credential-api.ts +++ b/dashboard/src/v2/lib/automation-credential-api.ts @@ -70,8 +70,8 @@ const json = (method: string, body?: unknown): RequestInit => ({ const base = (projectId: string) => `/api/projects/${encodeURIComponent(projectId)}/credentials`; const credential = (projectId: string, id: string) => `${base(projectId)}/${encodeURIComponent(id)}`; -export const fetchCredentialHealth = (): Promise => request(() => fetchJson("/api/credentials/health")); -export const fetchAutomationCredentials = (projectId: string): Promise => request(() => fetchJson(base(projectId))); +export const fetchCredentialHealth = (signal?: AbortSignal): Promise => request(() => fetchJson("/api/credentials/health", { signal })); +export const fetchAutomationCredentials = (projectId: string, signal?: AbortSignal): Promise => request(() => fetchJson(base(projectId), { signal })); export const createAutomationCredential = (projectId: string, input: CreateAutomationCredentialInput): Promise => request(() => fetchJson(base(projectId), json("POST", input))); export const updateAutomationCredential = (projectId: string, id: string, input: UpdateAutomationCredentialMetadataInput): Promise => request(() => fetchJson(credential(projectId, id), json("PATCH", input))); export const bindAutomationCredential = (projectId: string, id: string, input: BindAutomationCredentialInput): Promise => request(() => fetchJson(`${credential(projectId, id)}/bind`, json("POST", input))); diff --git a/dashboard/src/v2/lib/custom-dashboard-api.ts b/dashboard/src/v2/lib/custom-dashboard-api.ts index 013b927cf7..8cd7f5d7a9 100644 --- a/dashboard/src/v2/lib/custom-dashboard-api.ts +++ b/dashboard/src/v2/lib/custom-dashboard-api.ts @@ -1,4 +1,14 @@ import { fetchJson } from "../../lib/api/fetch-json.js"; +import type { + AutomationCredentialCompatibilityIssue, + AutomationCredentialMetadata, + CredentialBackendHealth, +} from "../../../../src/contracts/automation-credential-types.js"; +import type { + CustomDashboardCredentialBinding, + CustomDashboardCredentialSlotDeclaration, + CustomDashboardValidationIssue, +} from "../../../../src/contracts/custom-dashboard-types.js"; import type { CreateCustomDashboardDraftInput, CreateCustomDashboardRevisionInput, @@ -43,8 +53,70 @@ export interface CustomDashboardValidationLogsResponse { logs: string; } +export interface CustomDashboardCredentialCandidate { + credentialId: string; + metadata: AutomationCredentialMetadata | null; + compatible: boolean; + issues: AutomationCredentialCompatibilityIssue[]; + missingCapabilities: string[]; +} + +export interface CustomDashboardCredentialSlotReview { + slot: CustomDashboardCredentialSlotDeclaration; + binding: CustomDashboardCredentialBinding | null; + metadata: AutomationCredentialMetadata | null; + compatible: boolean; + issues: CustomDashboardValidationIssue[]; + candidates?: CustomDashboardCredentialCandidate[]; +} + +export interface CustomDashboardCredentialBindingReview { + projectId: string; + dashboardId: string; + revisionId: string | null; + credentialBindingRevision: number | null; + backend: CredentialBackendHealth; + valid: boolean; + issues: CustomDashboardValidationIssue[]; + slots: CustomDashboardCredentialSlotReview[]; + credentialCandidateCount: number; + credentialCandidatesTruncated: boolean; +} + +export class CustomDashboardCredentialBindingApiError extends Error { + constructor( + readonly status: number, + message: string, + readonly issues: CustomDashboardValidationIssue[] = [], + ) { + super(message); + this.name = "CustomDashboardCredentialBindingApiError"; + } +} + const jsonHeaders = { "Content-Type": "application/json" }; +const credentialBindingsPath = (projectId: string, dashboardId: string): string => ( + `/api/projects/${encodeURIComponent(projectId)}/custom-dashboards/${encodeURIComponent(dashboardId)}/credential-bindings` +); + +const mutateCredentialBindings = async ( + path: string, + init: RequestInit, +): Promise => { + const response = await fetch(path, { ...init, cache: "no-store" }); + const body = await response.json().catch(() => ({})) as Partial & { + error?: unknown; + issues?: unknown; + }; + if (!response.ok) { + const message = typeof body.error === "string" ? body.error : `Credential binding request failed: ${path}`; + const issues = Array.isArray(body.issues) ? body.issues as CustomDashboardValidationIssue[] : []; + throw new CustomDashboardCredentialBindingApiError(response.status, message, issues); + } + return body as CustomDashboardCredentialBindingReview; +}; + export const fetchCustomDashboards = ( projectId: string, signal?: AbortSignal, @@ -59,6 +131,43 @@ export const fetchCustomDashboard = ( fetchJson(`/api/custom-dashboards/${encodeURIComponent(dashboardId)}`, { signal }) ); +export const fetchCustomDashboardCredentialBindings = ( + projectId: string, + dashboardId: string, + signal?: AbortSignal, +): Promise => ( + fetchJson(credentialBindingsPath(projectId, dashboardId), { signal }) +); + +export const bindCustomDashboardCredential = ( + projectId: string, + dashboardId: string, + input: { slotId: string; credentialId: string; expectedBindingRevision: number }, + signal?: AbortSignal, +): Promise => ( + mutateCredentialBindings(credentialBindingsPath(projectId, dashboardId), { + method: "PUT", + headers: jsonHeaders, + body: JSON.stringify(input), + signal, + }) +); + +export const unbindCustomDashboardCredential = ( + projectId: string, + dashboardId: string, + slotId: string, + expectedBindingRevision: number, + signal?: AbortSignal, +): Promise => ( + mutateCredentialBindings(`${credentialBindingsPath(projectId, dashboardId)}/${encodeURIComponent(slotId)}`, { + method: "DELETE", + headers: jsonHeaders, + body: JSON.stringify({ expectedBindingRevision }), + signal, + }) +); + export const createCustomDashboard = ( projectId: string, input: CreateCustomDashboardDraftInput, diff --git a/docs-web/content/docs/user-dashboard-custom-dashboards.mdx b/docs-web/content/docs/user-dashboard-custom-dashboards.mdx index 327846bc5b..9bc9006971 100644 --- a/docs-web/content/docs/user-dashboard-custom-dashboards.mdx +++ b/docs-web/content/docs/user-dashboard-custom-dashboards.mdx @@ -7,7 +7,7 @@ Custom dashboards are project-scoped dashboard apps generated and revised by age 1. Ask the Project Manager for the dashboard you want. Include the purpose, target audience, data sources, layout preferences, review criteria, and whether it should be published after validation. 2. Review the draft at `/custom-dashboards`. Drafts expose manifest JSON, generated file bundle content, source-node graph JSON, styleguide JSON, and data catalog selections. 3. Ask for changes or edit the draft before creating a revision. Draft edits do not change previous revisions or the currently published dashboard. -4. If the manifest declares credential slots, review them through the credential-binding management surface. Bind each required slot to a compatible credential ID; no secret value is entered into the dashboard draft or generated code. +4. If the manifest declares credential slots, open the editor's **Credentials** tab. It shows bounded declarations and current non-secret metadata, and offers only active, configured, project-authorized credentials that satisfy the declared kinds and capabilities. Bind every required slot; no secret value is entered into the dashboard draft or generated code. 5. Create a revision when the draft is ready. A revision snapshots the current manifest, files, source graph, styleguide, runtime metadata, and credential-ID bindings. 6. Run detached validation. Code UX reviews bindings before it builds the revision in Docker, captures the browser-ready Vite artifact, starts a detached preview container, and health-checks the root URL. 7. Inspect validation status, logs, and the proxied preview link. Validation passes only after credential policy, install, build, artifact capture, container start, and root health checks succeed. @@ -16,6 +16,8 @@ Custom dashboards are project-scoped dashboard apps generated and revised by age If validation fails, use the report and logs to create a new revision. Code UX rejects failed, queued, running, cancelled, missing, or mismatched validation sessions before publication state changes. When a dashboard is already published, validating later drafts keeps the active published dashboard open, and validation sessions for the active published revision do not replace its published validation snapshot. +The Credentials tab appears only for manifests with declared slots. Unavailable secure custody and empty compatible lists link to credential management in Settings. Binding changes use the current optimistic revision; a conflict refreshes the dashboard and asks for an explicit retry. Required unbinding marks the next revision as not ready, while optional unbound slots remain valid. Successful changes refresh validation and publication readiness. The controls support keyboard selection, visible focus, focus restoration, and live save/error announcements. Credential IDs stay out of manifest, file, source, styleguide, and runtime text editors, and the editor never requests or displays secret values. + ## Data Sources Custom dashboards declare a `sourceNodeGraph` with nodes, edges, and optional metadata. Nodes have `id`, `type`, `title`, and optional JSON `config`. diff --git a/docs-web/user/dashboard/custom-dashboards.md b/docs-web/user/dashboard/custom-dashboards.md index 327846bc5b..9bc9006971 100644 --- a/docs-web/user/dashboard/custom-dashboards.md +++ b/docs-web/user/dashboard/custom-dashboards.md @@ -7,7 +7,7 @@ Custom dashboards are project-scoped dashboard apps generated and revised by age 1. Ask the Project Manager for the dashboard you want. Include the purpose, target audience, data sources, layout preferences, review criteria, and whether it should be published after validation. 2. Review the draft at `/custom-dashboards`. Drafts expose manifest JSON, generated file bundle content, source-node graph JSON, styleguide JSON, and data catalog selections. 3. Ask for changes or edit the draft before creating a revision. Draft edits do not change previous revisions or the currently published dashboard. -4. If the manifest declares credential slots, review them through the credential-binding management surface. Bind each required slot to a compatible credential ID; no secret value is entered into the dashboard draft or generated code. +4. If the manifest declares credential slots, open the editor's **Credentials** tab. It shows bounded declarations and current non-secret metadata, and offers only active, configured, project-authorized credentials that satisfy the declared kinds and capabilities. Bind every required slot; no secret value is entered into the dashboard draft or generated code. 5. Create a revision when the draft is ready. A revision snapshots the current manifest, files, source graph, styleguide, runtime metadata, and credential-ID bindings. 6. Run detached validation. Code UX reviews bindings before it builds the revision in Docker, captures the browser-ready Vite artifact, starts a detached preview container, and health-checks the root URL. 7. Inspect validation status, logs, and the proxied preview link. Validation passes only after credential policy, install, build, artifact capture, container start, and root health checks succeed. @@ -16,6 +16,8 @@ Custom dashboards are project-scoped dashboard apps generated and revised by age If validation fails, use the report and logs to create a new revision. Code UX rejects failed, queued, running, cancelled, missing, or mismatched validation sessions before publication state changes. When a dashboard is already published, validating later drafts keeps the active published dashboard open, and validation sessions for the active published revision do not replace its published validation snapshot. +The Credentials tab appears only for manifests with declared slots. Unavailable secure custody and empty compatible lists link to credential management in Settings. Binding changes use the current optimistic revision; a conflict refreshes the dashboard and asks for an explicit retry. Required unbinding marks the next revision as not ready, while optional unbound slots remain valid. Successful changes refresh validation and publication readiness. The controls support keyboard selection, visible focus, focus restoration, and live save/error announcements. Credential IDs stay out of manifest, file, source, styleguide, and runtime text editors, and the editor never requests or displays secret values. + ## Data Sources Custom dashboards declare a `sourceNodeGraph` with nodes, edges, and optional metadata. Nodes have `id`, `type`, `title`, and optional JSON `config`. diff --git a/docs/dashboard/custom-dashboards.md b/docs/dashboard/custom-dashboards.md index 84a5633dc1..91e340f6d6 100644 --- a/docs/dashboard/custom-dashboards.md +++ b/docs/dashboard/custom-dashboards.md @@ -9,7 +9,7 @@ The source of truth is the Code UX database. Drafts stay mutable, revisions are 1. Ask the Project Manager for the dashboard you want. Include the purpose, target audience, data sources, layout preferences, review criteria, and whether the dashboard should be published after validation. 2. Review the draft in the dashboard workspace at `/custom-dashboards`. The draft includes editable manifest JSON, generated file bundle content, source-node graph JSON, styleguide JSON, and data catalog selections. 3. Ask for changes or edit the draft before creating a revision. Draft edits do not change previous revisions or the currently published dashboard. -4. If the manifest declares credential slots, review them through the credential-binding management surface. Bind each required slot to a compatible credential ID; no secret value is entered into the dashboard draft or generated code. +4. If the manifest declares credential slots, open the editor's **Credentials** tab. It shows each bounded declaration and current non-secret credential metadata, and offers only active, configured, project-authorized credentials that satisfy the allowed kinds and required capabilities. Bind every required slot; no secret value is entered into the dashboard draft or generated code. 5. Create a revision when the draft is ready. A revision snapshots the current manifest, file bundle, source graph, styleguide, runtime metadata, and credential-ID bindings. 6. Run detached validation for the revision. Code UX reviews bindings before it materializes the bundle, builds it in Docker, starts a detached preview container, and health-checks the root URL. 7. Inspect validation status, logs, and the proxied preview link. Validation passes only after credential policy, install, build, browser artifact capture, container start, and root health checks succeed. A passed validation does not publish by itself. @@ -18,6 +18,10 @@ The source of truth is the Code UX database. Drafts stay mutable, revisions are If validation fails, use the report and logs to create a new revision. Do not publish around the failure; the repository rejects failed, queued, running, cancelled, missing, or mismatched validation sessions before publication state changes. When a dashboard is already published, validating later drafts keeps the active published dashboard open, and validation sessions for the active published revision do not replace its published validation snapshot. +The Credentials tab appears only when the saved manifest declares slots. Secure-backend failures and empty compatible lists link to credential management in Settings. Binding, replacement, and unbinding use the current optimistic binding revision; a concurrent edit refreshes the dashboard and requires an explicit retry instead of overwriting the other operator. Required unbinding immediately shows the draft as not ready for its next revision, while optional unbound slots remain valid. Every successful binding change refreshes validation and publication readiness. + +Credential selection and actions are keyboard accessible, restore focus after completion, and announce saving or error state. Credential IDs remain confined to the dedicated metadata-management request state and never enter manifest, generated-file, source-graph, styleguide, runtime-text, or secret-value fields. + ## Agent Workflow Project Manager agents should use the `manage_custom_dashboards` MCP surface rather than writing generated code into `dashboard/src`. From fe86a4692dec11b01809c29a2f182e65386a5e27 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 23:43:50 +0000 Subject: [PATCH 16/22] feat(task T09): implement via codex --- .../custom-dashboard-foundation.md | 10 +- .../node-flow-builtins-and-security.md | 69 +++-- docs-web/architecture/node-flows.md | 156 +++++++++-- ...chitecture-custom-dashboard-foundation.mdx | 10 +- ...ecture-node-flow-builtins-and-security.mdx | 69 +++-- .../content/docs/architecture-node-flows.mdx | 156 +++++++++-- .../docs/operations-credential-security.mdx | 64 +++-- .../content/docs/operations-server-mode.mdx | 250 ++++++++++++++++-- docs-web/content/docs/registry.ts | 14 +- .../content/docs/settings-integrations.mdx | 36 ++- .../docs/user-dashboard-custom-dashboards.mdx | 159 ++++++++--- .../docs/user-dashboard-node-flows.mdx | 36 +-- docs-web/operations/credential-security.md | 64 +++-- docs-web/operations/server-mode.md | 250 ++++++++++++++++-- docs-web/settings/integrations.md | 44 ++- docs-web/user/dashboard/custom-dashboards.md | 159 ++++++++--- docs-web/user/dashboard/node-flows.md | 36 +-- docs/SUMMARY.md | 1 + .../custom-dashboard-foundation.md | 8 + .../node-flow-builtins-and-security.md | 6 + docs/architecture/node-flows.md | 8 + docs/dashboard/custom-dashboards.md | 4 + docs/dashboard/node-flows.md | 2 + docs/index.md | 1 + docs/operations/credential-security.md | 12 + docs/operations/server-mode.md | 5 + docs/settings/integrations.md | 17 +- 27 files changed, 1358 insertions(+), 288 deletions(-) diff --git a/docs-web/architecture/custom-dashboard-foundation.md b/docs-web/architecture/custom-dashboard-foundation.md index ae43447fa4..ba3a63d42d 100644 --- a/docs-web/architecture/custom-dashboard-foundation.md +++ b/docs-web/architecture/custom-dashboard-foundation.md @@ -15,6 +15,12 @@ Primary records: Dashboard status values are `draft`, `validating`, `validated`, `published`, `rejected`, and `archived`. Validation status values are `queued`, `building`, `running`, `passed`, `failed`, and `cancelled`. +### Feature baseline and bounded addition + +Repository history provides the negative baseline for this subsystem: at the pre-feature `dev` commit `716ac2c55`, `CustomDashboardManifest` had no `credentialSlots`, and mutable dashboard and immutable revision records had no `credentialBindings` or binding revision. The implemented change is intentionally limited to bounded manifest declarations, credential-ID bindings in dedicated draft/revision columns, metadata-only compatibility review, optimistic binding mutation, and validation/publication gates. It does not migrate provider secrets and it does not add custom-dashboard secret injection. + +Declarations are normalized and bounded for count, slot ID, label, phase (`build` or `runtime`), allowed kinds, and required capabilities. Bindings contain only `slotId` and `credentialId`; generic draft/revision writes cannot set them, and immutable revisions snapshot them. The phase is policy metadata for review and validation, not permission to inject a value into build or runtime artifacts. + ## Persistence SQLite tables are created in both the initial schema and startup migrations: @@ -59,7 +65,9 @@ Validation flow: - A validation session is marked `passed` only after install, build, start, and root URL health checks succeed. Build/start/health failures are recorded as failed validation reports with bounded log excerpts. - Runtime metadata persists the workspace path, log path, host port, container id/name, image, validation URL path, commands, latest error/log excerpt, and a browser-ready Vite `dist` artifact for passed revisions so the published viewer can render TSX-based drafts without a live validation container. -Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. REST and MCP publication re-run metadata-only binding review immediately before calling the repository, then require either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed binding review returns sanitized slot-specific issues without credential IDs or values; queued/running/cancelled validation, missing state, and cross-revision sessions are rejected before the publication pointer changes. +Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. REST and MCP publication re-run metadata-only binding review immediately before calling the repository, then require either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed binding review, queued/running/cancelled validation, missing state, and cross-revision sessions are rejected before the publication pointer changes. + +No custom-dashboard service resolves credential plaintext. Build workspaces, generated files and Vite artifacts, Docker arguments/mounts/environment, validation reports/logs, generic REST/MCP records, viewer configuration, iframe `srcdoc`, data-bridge payloads, and `postMessage` traffic receive neither credential values nor binding IDs. Only the dedicated metadata-management response may return binding IDs alongside non-secret credential metadata. ## REST and MCP Surface diff --git a/docs-web/architecture/node-flow-builtins-and-security.md b/docs-web/architecture/node-flow-builtins-and-security.md index 80324199ed..18174020be 100644 --- a/docs-web/architecture/node-flow-builtins-and-security.md +++ b/docs-web/architecture/node-flow-builtins-and-security.md @@ -1,29 +1,66 @@ # Node Flow Built-ins and External-Effect Security -The governed catalog adds deterministic branches, bounded collection processing, durable approvals, and replay-safe external effects while keeping the versioned definition registry as the executable authority. +The governed built-in catalog extends publication-based node-flow execution with deterministic control nodes and durable boundaries for external effects. The definition registry remains the executable authority; a graph can only run a node when its versioned manifest is registered and executable. -## Control and integration nodes +## Built-in catalog -- `condition` selects `true` or `false`; `switch` selects one named case or `default`. Unselected branches persist as skipped node runs. -- `foreach` rejects inputs above its configured bound (never more than 1,000), then runs downstream nodes once per deterministic logical item. Configured concurrency defaults to one and is capped at 64; zero items explicitly select `empty`. `merge` supports `object`, `array`, and `first` strategies. -- `delay` is cancellable and capped at one hour. `execute_subflow` requires same-project ownership, rejects direct self-reference, and caps depth at eight. -- `approval` persists an idempotent operator decision and continues the exact pinned run after approval. `email_draft` never sends. `email_send` requires approval and uses the idempotent outbox. -- `webhook_trigger` emits payloads accepted through secret-authenticated webhook ingress. +| Node | Contract | +| --- | --- | +| `condition` | Evaluates a bounded operator and selects exactly the `true` or `false` output port. Unselected branches persist as skipped node runs. | +| `switch` | Evaluates no more than 100 configured cases and selects one named case or `default`. | +| `foreach` | Validates an array, rejects inputs above the configured bound (at most 1,000), and executes the selected downstream branch once per logical item with bounded concurrency. | +| `merge` | Combines active upstream values with `object`, `array`, or `first` strategy. | +| `delay` | Waits for a cancellable duration from zero through one hour. | +| `approval` | Creates or reuses a durable approval keyed by run, node, and logical item. | +| `email_draft` | Produces a draft only and never contacts a provider. | +| `email_send` | Requires an approved decision, then dispatches through the idempotent outbox. | +| `execute_subflow` | Executes a published flow owned by the same project, rejects direct self-reference, and caps nesting at eight. | +| `webhook_trigger` | Emits input accepted by a secret-authenticated webhook configuration. | -## Network policy +The existing `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, and `output` nodes retain their previous contracts. Typed manifest ports identify branch handles, many-valued merge inputs, and trigger outputs. Branch routing only runs a node when at least one incoming edge is active, allowing merges to join a selected path without treating an unselected sibling as a failure. -HTTP nodes and future custom nodes use the same `EgressPolicyService`. HTTPS is required unless HTTP is explicitly enabled. Private, loopback, link-local, metadata, multicast, and other non-public addresses remain blocked in both modes. Credentials in URLs and raw restricted headers are rejected. +## Credential-bound execution -Every redirect is manually revalidated. DNS is checked for private results and rebinding. Host and port allowlists, response-size and content-type limits, propagated cancellation and timeouts, capped retries, idempotency requirements for unsafe retry, normalized headers, and per-key rate windows keep requests bounded. +Versioned definition manifests declare credential slots by required state, allowed kinds, and required capabilities. Draft review and publication use metadata-only broker compatibility; the canonical graph stores only slot-to-credential-ID bindings. Required missing bindings and bindings denied for unavailable custody, configuration, status, project access, kind, or capability stop publication. Optional unbound slots remain valid. -## OAuth, approvals, and outbox +At runtime, the immutable published graph is revalidated and the broker repeats authorization immediately before resolving a value for the active attempt. A revoked, restricted, rebound, wrong-kind, insufficiently capable, or unavailable credential fails the attempt closed. Exact resolved values are redacted from built-in output, invocation/attempt records, diagnostics, retries, HTTP/provider responses, and external-effect persistence; neither publication nor MCP inspection injects or returns plaintext. -Pending approvals preserve the run, governed node, logical item, and numbered attempt. Approved decisions resume at that node boundary; rejected and expired decisions terminate durably. Repeated decisions and restart recovery do not create a second approval request, attempt, or external delivery. +Foreach assigns deterministic logical-item identities from the published node id and item index. Each downstream node run and numbered attempt persists that identity together with the item-specific input. The `concurrency` setting defaults to one and is capped at 64; `maxItems` is a rejection bound rather than a truncation rule. A zero-item input selects `empty`, while the `items` branch is persisted as skipped. Per-item failures retain their own retry history, successful siblings are not replayed during approval or restart continuation, and aggregated output preserves input order. -Foreach descendant node runs and attempts persist item-specific inputs and logical identity. Retries remain item-local, completed siblings are reconstructed rather than replayed after restart, and aggregation preserves input order. +## Governed egress -OAuth authorization uses PKCE S256 and short-lived AES-256-GCM state tied to an allowlisted callback origin. Tokens live behind the connection store, rotate on refresh, enforce scopes and expiry, and are never written into graph JSON or agent-visible output. Revocation, reconnect, and health checks expose no token values. +`EgressPolicyService` is the single request boundary for HTTP nodes and future custom-node network calls. HTTPS is required by default. A node must explicitly opt into HTTP, and even then private networking remains blocked. The service rejects credentials embedded in URLs; loopback, private, link-local, carrier-grade NAT, benchmarking, multicast, and cloud-metadata addresses; metadata hostnames; restricted raw headers; and ports or hosts outside configured allowlists. -Approvals are unique per run, node, and logical item. Outbox entries use a unique key derived from publication, run, node, and logical item, and store the provider message id after success. A restart while an entry is sending changes it to `attention_required`; Code UX does not automatically replay an unknown provider outcome. +Each redirect is handled manually and fully revalidated. DNS is resolved twice before dispatch, and a changed or newly private result is treated as rebinding. Cross-origin redirects remove credential headers. Response bodies are streamed into a bounded buffer, content types are allowlisted, timeouts and caller cancellation propagate, retry counts are capped, unsafe methods require an idempotency key before retry, and an in-process rate window bounds requests per project and host. -Webhook configuration returns a newly rotated path token and secret while persisting only their hashes. Ingress requires `x-codeux-webhook-secret` and dispatches the latest published flow version. +## OAuth boundary + +`OAuthBroker` implements authorization-code flow with PKCE S256. Authorization state is authenticated AES-256-GCM ciphertext containing a short expiry, callback origin, redirect URI, verifier, connection id, and nonce. Callback origins must be explicitly allowlisted and match the state. Token exchange and refresh results are stored behind an `OAuthConnectionStore`; access and refresh tokens are returned only to provider-bound execution code, never to graph JSON or agent-visible output. + +Refresh happens shortly before expiry and rotates the stored refresh token when the provider returns one. Required scopes are checked before access. Revocation deletes local state after provider revocation; reconnect begins from a revoked local connection; health checks refresh when necessary and expose only health, expiry, and scopes. + +## Approvals and outbox + +`automation_approvals` persists pending and terminal decisions. Repeating the same run, node, and logical item returns the existing decision, so restarts do not create a second prompt. Repeating an identical decision is also idempotent. Approval or rejection through the decision endpoint resumes or terminates the exact waiting run; approval preserves its publication, run id, logical item, and attempt number. Email sending is approval-gated by default; `email_draft` is the non-irreversible default. + +`automation_outbox` has a unique SHA-256 idempotency key derived from publication id, run id, node id, and logical item. Provider message ids are stored after success. A process restart while an entry is `sending` changes it to `attention_required`, because the provider may have accepted the operation; Code UX does not replay an unknown external outcome automatically. + +## Webhook routes + +Creating `POST /api/node-flows/:flowId/webhook` rotates and returns a path token and secret once. Only their hashes are persisted. `POST /api/webhooks/node-flows/:pathToken` requires the secret in `x-codeux-webhook-secret`, uses constant-time digest comparison, and dispatches the latest published version with `triggerType: webhook`. The response returns only run identity and status. + +Example condition edges use explicit handles: + +```json +{ + "nodes": [ + { "id": "check", "type": "condition", "title": "Check", "data": { "path": "input.enabled" } }, + { "id": "draft", "type": "email_draft", "title": "Draft", "data": { "to": "owner@example.test", "subject": "Ready", "body": "Review this draft." } }, + { "id": "done", "type": "output", "title": "Done" } + ], + "edges": [ + { "fromNodeId": "check", "fromHandle": "true", "toNodeId": "draft" }, + { "fromNodeId": "check", "fromHandle": "false", "toNodeId": "done" } + ] +} +``` diff --git a/docs-web/architecture/node-flows.md b/docs-web/architecture/node-flows.md index 1dce58b423..4a20a15ffe 100644 --- a/docs-web/architecture/node-flows.md +++ b/docs-web/architecture/node-flows.md @@ -1,30 +1,138 @@ # Node Flows -Node flows are project-owned, versioned Graph v2 workflows. +Node flows are project-scoped, repeatable workflow graphs for turning an operator or agent-defined procedure into a saved Code UX workflow. They are not a generic n8n compatibility layer. A good flow uses Code UX concepts, project-owned records, provider settings, execution invocations, and editable widget schemas so the same workflow can be inspected, rerun, scheduled, and attached to agents. -Authenticated dashboard routes resolve persisted project ownership from flow, run, or approval ids before authorizing the request. Drafts, publications, comparisons, rollbacks, attachments, webhook configuration, debugger data, attempts, cancellation, retry, and approvals cannot be accessed by presenting a different body or query project id. Webhook ingress remains on its path-token and webhook-secret scheme, with dashboard host and browser-origin protections still enforced. +The foundation page in [Node Flow Foundation](./node-flow-foundation.md) lists the low-level contracts. This page describes the end-to-end architecture and runtime expectations for developers and specialist agents. -## Implemented runtime nodes +## Data Model -| Type | Execution | +Node-flow persistence is owned by `NodeFlowRepository` and stored in SQLite: + +| Table | Purpose | +| --- | --- | +| `node_flows` | Current project-scoped flow record: id, project id, title, description, normalized `graph_json`, current version, and timestamps. | +| `node_flow_versions` | Immutable edit snapshots written on create and every update. | +| `node_flow_publications` | Immutable executable graph and execution-policy snapshots selected by pinned or latest-published runs. | +| `node_flow_agent_skills` | Agent attachment table keyed by flow and agent preset. It stores the skill display name and description used when exposing the flow as a repeatable agent capability. | +| `node_flow_runs` | Flow run records with status, version, trigger type, redacted trigger payload, redacted input/output, error message, timestamps, and optional `execution_invocation_id`. | +| `node_flow_node_runs` | Per-node run records with status, node id, redacted input/output, error message, timestamps, and optional `execution_invocation_id`. | +| `node_flow_node_attempts` | Numbered attempts with executor/invocation identity, artifact digest, credential ids, redacted payloads, failure class, and retry decision. | + +All graphs, widget schemas, run inputs, outputs, and trigger payloads are stored as JSON text and hydrated into typed contracts at the repository boundary. Flow, version, run, and attachment records belong to a project. Agent attachment operations verify that the target agent preset belongs to the same project as the flow. + +Authenticated dashboard requests resolve project ownership from the persisted flow, run, or approval record before role and project authorization. This applies to ID-only draft, publication, comparison, rollback, attachment, webhook-configuration, run debugger, attempt, cancellation, retry, and approval routes; a caller-supplied body or query project id is not treated as proof of ownership. Webhook ingress is the exception to dashboard bearer authentication and continues to use its path-token and webhook-secret scheme, while dashboard host and browser-origin protections still apply. + +## Graph Contract + +The shared contract lives in `src/contracts/node-flow-types.ts`. + +A `NodeFlowGraph` contains: + +- `nodes`: stable node ids, string node `type`, title, optional description, optional position, optional `widgetSchema`, and JSON `data`. +- `edges`: directed links from `fromNodeId` to `toNodeId`. +- `inputSchema`: optional graph-level widget schema for run input. +- `metadata`: optional JSON object for non-secret descriptive data. + +Validation is owned by `src/domain/node-flows/node-flow-validation.ts`. It normalizes ids, labels, positions, widget defaults, and graph shape; rejects missing node/edge arrays; rejects duplicate node ids; rejects edges that point at missing nodes; requires at least one node; and rejects cycles. Widget validation supports `text`, `textarea`, `number`, `boolean`, `select`, `json`, `secretRef`, and `keyValue` fields. + +Migration and validation treat persisted Graph v1 and canonical Graph v2 as untrusted input. Malformed collection members are rejected at their original index, such as `nodes[1].ports[0]` or `edges[2]`, while structurally valid siblings remain available to the rest of normalization. Definition references, credential bindings, capabilities, policies, port and graph schemas, and JSON metadata emit deterministic field-level issues instead of throwing. Revalidating the same graph produces the same ordered issue list. + +Validation requires every node's type/version reference to resolve through the registry and rejects unknown definitions. Runtime execution then dispatches according to the registered definition's executable state and execution kind; a planning concept is not runnable merely because it has a string type. + +The dashboard uses the same backend-owned Graph v2 record as the runtime. The selected project controls library loading; no project means no flow, credential, publication, or run requests. The registry list endpoint returns flat palette summaries, while the node-type detail endpoint returns a complete `NodeDefinitionManifest` with nested `ui`, schemas, policies, documentation, and deprecation metadata. The inspector consumes that full manifest. Draft saves use optimistic `draftRevision` checks and surface conflicts without overwriting the newer record. + +`dashboard/src/v2/lib/nodes-canvas-state.ts` remains only a compatibility and pure graph-state layer. Its legacy browser graph can be imported once into a project draft. The adapter translates `trigger`/`agent`/`task` into registered `input`/`set_fields`/`provider_prompt` nodes, remaps legacy handles to governed ports, and retains non-secret canvas metadata. Import failure is isolated from the normal library load; only a successful draft creation removes the old graph key and records the project marker. Browser storage is not the workflow source of truth. + +### Credential binding lifecycle + +Each versioned node definition is the slot-policy authority: every slot declares whether it is required, its allowed credential kinds, and all required capabilities. The picker lists project-visible metadata, then filters each candidate through secure-backend readiness, configured/active state, project access, kind, and capability compatibility. It never resolves a value. + +`NodeFlowNode.credentialBindings` is the only persisted binding source. Selecting, replacing, or unbinding a credential changes the matching `{ slot, credentialId }` entry in the complete canonical graph and saves with the current `draftRevision`. The dashboard adopts the returned graph and revision, then refreshes governed review. A `409`-style revision conflict refreshes the latest draft and requires a deliberate retry; it never replays a stale binding over sibling changes. + +Required unbound slots and any bound credential denied by backend readiness, configuration, active status, project access, allowed kind, or required capabilities block publication. Optional unbound slots do not. Runtime revalidates the immutable publication and repeats the same policy immediately before direct credential-ID resolution, so revocation, restriction, rotation/rebinding races, missing custody, or incompatible policy deny the node attempt rather than injecting stale plaintext. Graph, review, publication, MCP, and dashboard payloads contain IDs and non-secret policy metadata only. + +## Runtime + +`NodeFlowRuntimeService.runFlow(projectId, flowId, input, options)` resolves an explicit pinned or latest-published snapshot, revalidates that immutable graph, claims a durable lease, and executes nodes in topological order. See [Node Flow Durable Execution](./node-flow-durable-execution.md) for queue, retry, lease, recovery, quota, and redaction guarantees. + +Runtime-supported node types are: + +| Node type | Behavior | | --- | --- | -| `input` | Emits run input. | -| `set_fields` | Transforms object fields. | -| `template` | Renders text templates. | -| `provider_prompt` | Invokes a configured CLI provider. | -| `http_request` | Performs a bounded HTTP/HTTPS request. | -| `condition`, `switch` | Selects one explicit output branch and persists unselected branches as skipped. | -| `foreach`, `merge` | Bounds item fan-out and combines active inputs with an explicit strategy. | -| `delay`, `approval` | Waits with cancellation or persists an operator decision gate. | -| `email_draft`, `email_send` | Produces a draft, or sends only after approval through the idempotent outbox. | -| `execute_subflow` | Executes a same-project published flow with recursion bounds. | -| `webhook_trigger` | Emits secret-authenticated webhook input. | -| `output` | Selects the result. | - -These are the executable definitions. Other custom palette concepts remain non-executable until a versioned handler is registered. Graph v1 migration preserves the legacy snapshot and appends deterministic v2. Both migrated v1 and canonical v2 graphs are validated as untrusted input: malformed nested members fail closed with stable paths at their original array indices, valid siblings are retained where safe, and repeated validation returns the same ordered issues instead of throwing. - -The separate browser-canvas compatibility bridge translates legacy `trigger`, `agent`, and `task` kinds into registered `input`, `set_fields`, and `provider_prompt` nodes and remaps their handles before draft creation. Import failure is reported without blocking the selected project's existing flow library. - -Execution uses immutable publications rather than the mutable editor row. Runs select a pinned publication or the latest published version, then use durable queue claims, leases, bounded quotas, timeout/cancellation propagation, and numbered retry attempts. Expired external attempts with unknown outcomes require operator attention and are not silently replayed. See [Node Flow Durable Execution](./node-flow-durable-execution.md). - -HTTP and future custom-node requests share one HTTPS-first egress policy with URL-credential rejection, DNS and redirect revalidation, private-network and metadata blocking, host/port allowlists, bounded content, retries, timeouts, and rate limits. See [Built-ins and External-Effect Security](./node-flow-builtins-and-security.md). +| `input` | Emits the run input object. | +| `set_fields` | Merges upstream object output with configured `fields` or `values`; set `replace: true` to ignore upstream output. | +| `template` | Renders `template` or `prompt` into `outputKey` (default `text`). | +| `provider_prompt` | Renders a prompt and calls an existing CLI provider configuration through `ProviderExecutionService`. | +| `http_request` | Performs bounded HTTP/HTTPS requests with method, URL, headers, query, body, timeout, and optional JSON path extraction. | +| `condition`, `switch` | Select one explicit output branch; non-selected branches are persisted as skipped. | +| `foreach` | Validates and emits a bounded item list. | +| `merge` | Combines active inputs with `object`, `array`, or `first` strategy. | +| `delay` | Waits for a bounded cancellable duration. | +| `approval` | Persists an operator decision gate. | +| `email_draft`, `email_send` | Creates a draft, or sends only after approval through the idempotent outbox. | +| `execute_subflow` | Executes a same-project published subflow with recursion bounds. | +| `webhook_trigger` | Emits authenticated webhook input. | +| `output` | Selects final output from a path, configured fields, or upstream output. | + +Template interpolation reads from `{{ input.path }}` and `{{ nodes.nodeId.path }}`. Node config is built from widget defaults, node `data`, and optional `data.values`, with later values overriding defaults. + +Provider prompt nodes require a configured CLI provider. HTTP nodes require HTTPS unless HTTP is explicitly enabled and support `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `HEAD`. Requests pass through the shared SSRF, redirect, DNS, response-size, content-type, retry, timeout, and rate-limit policy described in [Node Flow Built-ins and External-Effect Security](./node-flow-builtins-and-security.md). + +## Invocation Tracking + +Node flows use `execution_invocations` as the observable runtime surface: + +- each flow run creates a parent invocation with `type: "node_flow"` +- externally observable node steps create invocation rows with `type: "node_flow_node"` +- `node_flow_runs.execution_invocation_id` links the run record to the parent invocation +- `node_flow_node_runs.execution_invocation_id` links provider and HTTP node rows to their invocation record + +Only `provider_prompt` and `http_request` nodes currently create `node_flow_node` invocation rows. Deterministic local nodes still create `node_flow_node_runs` rows, but do not create extra execution invocations. + +Provider prompt nodes pass an existing invocation id into `ProviderExecutionService` and disable prompt/assistant transcript capture for raw prompt content. HTTP nodes append a redacted request summary. Flow run inputs, outputs, node input/output payloads, trigger payloads, graph data, and MCP responses redact secret-shaped keys such as `apiKey`, `authorization`, `cookie`, `password`, `secret`, and `token`. + +## Failure Semantics + +A failed node fails the flow and persists skipped records for downstream descendants by default. If a node has `data.continueOnError = true`, the failed node records `{ "error": "" }` as output and downstream nodes may continue. + +Cancellation records cancelled node rows for the current and remaining nodes. At completion, the parent invocation is updated to `completed`, `failed`, or `cancelled` to match the flow outcome. + +## Scheduling + +Scheduler entries with `targetType: "node_flow"` persist an explicit `versionSelection`: pinned schedules continue to execute version N after N+1 is published, while latest-published schedules resolve the newest publication at dispatch time. Legacy `flowVersion` values normalize to pinned selection and are executable semantics, not audit-only metadata. Ownership is validated when entries are created or updated and again before due-run execution. + +Due runs call `NodeFlowRuntimeService.runFlow` with `triggerType = "scheduler"` and trigger payload metadata for the scheduler entry id, scheduled occurrence time, target type, and persisted flow version when present. Node-flow schedules advance only when `runFlow` returns a run status of `succeeded`. Returned `failed` or `cancelled` runs mark the scheduler entry `failed` with the run error and still count the attempted occurrence in `lastRunAt` and `runCount`; runtime startup rejections mark failure without creating a false successful schedule run. + +## Agent Skill Attachment + +`node_flow_agent_skills` exposes a saved flow to an agent preset as a repeatable skill. Attachment stores `flow_id`, `project_id`, `agent_preset_id`, `skill_name`, `description`, and timestamps. + +Attachment does not copy the graph into the agent preset, and detach removes only the binding. The flow remains project-owned and can still be edited, scheduled, manually run, or attached to other agents. + +## Agent Design Guidance + +Specialist agents designing node flows should adapt workflows to Code UX instead of copying n8n or another tool one node at a time. + +Use these rules: + +- Model the repeatable outcome first, then choose the smallest Code UX graph that captures the inputs, provider calls, HTTP calls, transformations, and final output. +- Use the governed executable built-ins listed in the runtime table when the workflow needs to execute today. A registered custom definition is executable only when its validated immutable artifact and custom runtime are available. Treat unknown types, legacy browser-only kinds, and non-executable manifests as planned or unavailable definitions. +- Put operator-editable values in `inputSchema` or per-node `widgetSchema` fields. Do not bury frequently changed values in opaque JSON blobs. +- Use `secretRef` widgets and secret reference strings for credentials. Do not place raw API keys, bearer tokens, cookies, passwords, or private headers in graph metadata, node data, widget defaults, run input, or examples. +- Validate every node field before saving: required prompt/template/url fields, finite numeric limits, supported HTTP method, JSON object input, and select defaults that match options. +- Keep flows deterministic and rerunnable. Avoid hidden dependence on local time, ambient chat state, or one-off sprint context unless it is explicitly passed as JSON input. +- Preserve inspection value. Name nodes for the operation they perform, keep edges acyclic, and make the output node return the artifact another operator or agent will actually consume. + +## Graph v2 contract and migration + +Graph v2 is the single workflow model used by backend, MCP, runtime, and dashboard. It adds `schemaVersion: 2`, stable definition references, typed ports and flow schemas, credential-id bindings, retry and timeout policies, capability and side-effect metadata, disabled state, and optional immutable publication metadata. Plaintext credentials, secret-shaped fields, generated source, and custom code are not valid graph data. + +The executable registry contains the original deterministic/provider/HTTP nodes plus `condition`, `switch`, `foreach`, `merge`, `delay`, `approval`, `email_draft`, `email_send`, `execute_subflow`, and `webhook_trigger`. Unregistered custom types remain non-executable. + +Backend Graph v1 migration retains the exact prior version and appends deterministic v2. Invalid legacy members are carried across the migration boundary so validation can report their original paths rather than silently dropping them. Browser canvas v1 migration returns the untouched legacy snapshot separately from the normalized graph. + +## Dashboard and security prerequisites + +Outside development builds, the Nodes workspace is enabled only when `VITE_CODEUX_FEATURE_NODES`, `VITE_CODEUX_NODE_FLOW_BACKEND`, and `VITE_CODEUX_AUTOMATION_SECURITY` are true. These flags expose the surface; they do not replace runtime dependencies. Provider execution, credential resolution, outbound HTTP, approval-gated email, webhook ingress, and custom-node containers each require their corresponding configured service and security policy. + +The dashboard exposes credential binding ids, declared kinds, scopes, and status metadata only. Resolved values stay at the credential broker/runtime boundary and are redacted before invocation messages, attempt payloads, diagnostics, and route responses are persisted or rendered. Policy review must surface requested capabilities and external side effects before publication, and publication requires a valid current draft with all required bindings satisfied. diff --git a/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx b/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx index ae43447fa4..ba3a63d42d 100644 --- a/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx +++ b/docs-web/content/docs/architecture-custom-dashboard-foundation.mdx @@ -15,6 +15,12 @@ Primary records: Dashboard status values are `draft`, `validating`, `validated`, `published`, `rejected`, and `archived`. Validation status values are `queued`, `building`, `running`, `passed`, `failed`, and `cancelled`. +### Feature baseline and bounded addition + +Repository history provides the negative baseline for this subsystem: at the pre-feature `dev` commit `716ac2c55`, `CustomDashboardManifest` had no `credentialSlots`, and mutable dashboard and immutable revision records had no `credentialBindings` or binding revision. The implemented change is intentionally limited to bounded manifest declarations, credential-ID bindings in dedicated draft/revision columns, metadata-only compatibility review, optimistic binding mutation, and validation/publication gates. It does not migrate provider secrets and it does not add custom-dashboard secret injection. + +Declarations are normalized and bounded for count, slot ID, label, phase (`build` or `runtime`), allowed kinds, and required capabilities. Bindings contain only `slotId` and `credentialId`; generic draft/revision writes cannot set them, and immutable revisions snapshot them. The phase is policy metadata for review and validation, not permission to inject a value into build or runtime artifacts. + ## Persistence SQLite tables are created in both the initial schema and startup migrations: @@ -59,7 +65,9 @@ Validation flow: - A validation session is marked `passed` only after install, build, start, and root URL health checks succeed. Build/start/health failures are recorded as failed validation reports with bounded log excerpts. - Runtime metadata persists the workspace path, log path, host port, container id/name, image, validation URL path, commands, latest error/log excerpt, and a browser-ready Vite `dist` artifact for passed revisions so the published viewer can render TSX-based drafts without a live validation container. -Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. REST and MCP publication re-run metadata-only binding review immediately before calling the repository, then require either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed binding review returns sanitized slot-specific issues without credential IDs or values; queued/running/cancelled validation, missing state, and cross-revision sessions are rejected before the publication pointer changes. +Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. REST and MCP publication re-run metadata-only binding review immediately before calling the repository, then require either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed binding review, queued/running/cancelled validation, missing state, and cross-revision sessions are rejected before the publication pointer changes. + +No custom-dashboard service resolves credential plaintext. Build workspaces, generated files and Vite artifacts, Docker arguments/mounts/environment, validation reports/logs, generic REST/MCP records, viewer configuration, iframe `srcdoc`, data-bridge payloads, and `postMessage` traffic receive neither credential values nor binding IDs. Only the dedicated metadata-management response may return binding IDs alongside non-secret credential metadata. ## REST and MCP Surface diff --git a/docs-web/content/docs/architecture-node-flow-builtins-and-security.mdx b/docs-web/content/docs/architecture-node-flow-builtins-and-security.mdx index 80324199ed..18174020be 100644 --- a/docs-web/content/docs/architecture-node-flow-builtins-and-security.mdx +++ b/docs-web/content/docs/architecture-node-flow-builtins-and-security.mdx @@ -1,29 +1,66 @@ # Node Flow Built-ins and External-Effect Security -The governed catalog adds deterministic branches, bounded collection processing, durable approvals, and replay-safe external effects while keeping the versioned definition registry as the executable authority. +The governed built-in catalog extends publication-based node-flow execution with deterministic control nodes and durable boundaries for external effects. The definition registry remains the executable authority; a graph can only run a node when its versioned manifest is registered and executable. -## Control and integration nodes +## Built-in catalog -- `condition` selects `true` or `false`; `switch` selects one named case or `default`. Unselected branches persist as skipped node runs. -- `foreach` rejects inputs above its configured bound (never more than 1,000), then runs downstream nodes once per deterministic logical item. Configured concurrency defaults to one and is capped at 64; zero items explicitly select `empty`. `merge` supports `object`, `array`, and `first` strategies. -- `delay` is cancellable and capped at one hour. `execute_subflow` requires same-project ownership, rejects direct self-reference, and caps depth at eight. -- `approval` persists an idempotent operator decision and continues the exact pinned run after approval. `email_draft` never sends. `email_send` requires approval and uses the idempotent outbox. -- `webhook_trigger` emits payloads accepted through secret-authenticated webhook ingress. +| Node | Contract | +| --- | --- | +| `condition` | Evaluates a bounded operator and selects exactly the `true` or `false` output port. Unselected branches persist as skipped node runs. | +| `switch` | Evaluates no more than 100 configured cases and selects one named case or `default`. | +| `foreach` | Validates an array, rejects inputs above the configured bound (at most 1,000), and executes the selected downstream branch once per logical item with bounded concurrency. | +| `merge` | Combines active upstream values with `object`, `array`, or `first` strategy. | +| `delay` | Waits for a cancellable duration from zero through one hour. | +| `approval` | Creates or reuses a durable approval keyed by run, node, and logical item. | +| `email_draft` | Produces a draft only and never contacts a provider. | +| `email_send` | Requires an approved decision, then dispatches through the idempotent outbox. | +| `execute_subflow` | Executes a published flow owned by the same project, rejects direct self-reference, and caps nesting at eight. | +| `webhook_trigger` | Emits input accepted by a secret-authenticated webhook configuration. | -## Network policy +The existing `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, and `output` nodes retain their previous contracts. Typed manifest ports identify branch handles, many-valued merge inputs, and trigger outputs. Branch routing only runs a node when at least one incoming edge is active, allowing merges to join a selected path without treating an unselected sibling as a failure. -HTTP nodes and future custom nodes use the same `EgressPolicyService`. HTTPS is required unless HTTP is explicitly enabled. Private, loopback, link-local, metadata, multicast, and other non-public addresses remain blocked in both modes. Credentials in URLs and raw restricted headers are rejected. +## Credential-bound execution -Every redirect is manually revalidated. DNS is checked for private results and rebinding. Host and port allowlists, response-size and content-type limits, propagated cancellation and timeouts, capped retries, idempotency requirements for unsafe retry, normalized headers, and per-key rate windows keep requests bounded. +Versioned definition manifests declare credential slots by required state, allowed kinds, and required capabilities. Draft review and publication use metadata-only broker compatibility; the canonical graph stores only slot-to-credential-ID bindings. Required missing bindings and bindings denied for unavailable custody, configuration, status, project access, kind, or capability stop publication. Optional unbound slots remain valid. -## OAuth, approvals, and outbox +At runtime, the immutable published graph is revalidated and the broker repeats authorization immediately before resolving a value for the active attempt. A revoked, restricted, rebound, wrong-kind, insufficiently capable, or unavailable credential fails the attempt closed. Exact resolved values are redacted from built-in output, invocation/attempt records, diagnostics, retries, HTTP/provider responses, and external-effect persistence; neither publication nor MCP inspection injects or returns plaintext. -Pending approvals preserve the run, governed node, logical item, and numbered attempt. Approved decisions resume at that node boundary; rejected and expired decisions terminate durably. Repeated decisions and restart recovery do not create a second approval request, attempt, or external delivery. +Foreach assigns deterministic logical-item identities from the published node id and item index. Each downstream node run and numbered attempt persists that identity together with the item-specific input. The `concurrency` setting defaults to one and is capped at 64; `maxItems` is a rejection bound rather than a truncation rule. A zero-item input selects `empty`, while the `items` branch is persisted as skipped. Per-item failures retain their own retry history, successful siblings are not replayed during approval or restart continuation, and aggregated output preserves input order. -Foreach descendant node runs and attempts persist item-specific inputs and logical identity. Retries remain item-local, completed siblings are reconstructed rather than replayed after restart, and aggregation preserves input order. +## Governed egress -OAuth authorization uses PKCE S256 and short-lived AES-256-GCM state tied to an allowlisted callback origin. Tokens live behind the connection store, rotate on refresh, enforce scopes and expiry, and are never written into graph JSON or agent-visible output. Revocation, reconnect, and health checks expose no token values. +`EgressPolicyService` is the single request boundary for HTTP nodes and future custom-node network calls. HTTPS is required by default. A node must explicitly opt into HTTP, and even then private networking remains blocked. The service rejects credentials embedded in URLs; loopback, private, link-local, carrier-grade NAT, benchmarking, multicast, and cloud-metadata addresses; metadata hostnames; restricted raw headers; and ports or hosts outside configured allowlists. -Approvals are unique per run, node, and logical item. Outbox entries use a unique key derived from publication, run, node, and logical item, and store the provider message id after success. A restart while an entry is sending changes it to `attention_required`; Code UX does not automatically replay an unknown provider outcome. +Each redirect is handled manually and fully revalidated. DNS is resolved twice before dispatch, and a changed or newly private result is treated as rebinding. Cross-origin redirects remove credential headers. Response bodies are streamed into a bounded buffer, content types are allowlisted, timeouts and caller cancellation propagate, retry counts are capped, unsafe methods require an idempotency key before retry, and an in-process rate window bounds requests per project and host. -Webhook configuration returns a newly rotated path token and secret while persisting only their hashes. Ingress requires `x-codeux-webhook-secret` and dispatches the latest published flow version. +## OAuth boundary + +`OAuthBroker` implements authorization-code flow with PKCE S256. Authorization state is authenticated AES-256-GCM ciphertext containing a short expiry, callback origin, redirect URI, verifier, connection id, and nonce. Callback origins must be explicitly allowlisted and match the state. Token exchange and refresh results are stored behind an `OAuthConnectionStore`; access and refresh tokens are returned only to provider-bound execution code, never to graph JSON or agent-visible output. + +Refresh happens shortly before expiry and rotates the stored refresh token when the provider returns one. Required scopes are checked before access. Revocation deletes local state after provider revocation; reconnect begins from a revoked local connection; health checks refresh when necessary and expose only health, expiry, and scopes. + +## Approvals and outbox + +`automation_approvals` persists pending and terminal decisions. Repeating the same run, node, and logical item returns the existing decision, so restarts do not create a second prompt. Repeating an identical decision is also idempotent. Approval or rejection through the decision endpoint resumes or terminates the exact waiting run; approval preserves its publication, run id, logical item, and attempt number. Email sending is approval-gated by default; `email_draft` is the non-irreversible default. + +`automation_outbox` has a unique SHA-256 idempotency key derived from publication id, run id, node id, and logical item. Provider message ids are stored after success. A process restart while an entry is `sending` changes it to `attention_required`, because the provider may have accepted the operation; Code UX does not replay an unknown external outcome automatically. + +## Webhook routes + +Creating `POST /api/node-flows/:flowId/webhook` rotates and returns a path token and secret once. Only their hashes are persisted. `POST /api/webhooks/node-flows/:pathToken` requires the secret in `x-codeux-webhook-secret`, uses constant-time digest comparison, and dispatches the latest published version with `triggerType: webhook`. The response returns only run identity and status. + +Example condition edges use explicit handles: + +```json +{ + "nodes": [ + { "id": "check", "type": "condition", "title": "Check", "data": { "path": "input.enabled" } }, + { "id": "draft", "type": "email_draft", "title": "Draft", "data": { "to": "owner@example.test", "subject": "Ready", "body": "Review this draft." } }, + { "id": "done", "type": "output", "title": "Done" } + ], + "edges": [ + { "fromNodeId": "check", "fromHandle": "true", "toNodeId": "draft" }, + { "fromNodeId": "check", "fromHandle": "false", "toNodeId": "done" } + ] +} +``` diff --git a/docs-web/content/docs/architecture-node-flows.mdx b/docs-web/content/docs/architecture-node-flows.mdx index 8f1537e636..0b68846784 100644 --- a/docs-web/content/docs/architecture-node-flows.mdx +++ b/docs-web/content/docs/architecture-node-flows.mdx @@ -1,30 +1,138 @@ # Node Flows -Node flows are project-owned, versioned Graph v2 workflows. +Node flows are project-scoped, repeatable workflow graphs for turning an operator or agent-defined procedure into a saved Code UX workflow. They are not a generic n8n compatibility layer. A good flow uses Code UX concepts, project-owned records, provider settings, execution invocations, and editable widget schemas so the same workflow can be inspected, rerun, scheduled, and attached to agents. -Authenticated dashboard routes resolve persisted project ownership from flow, run, or approval ids before authorizing the request. Drafts, publications, comparisons, rollbacks, attachments, webhook configuration, debugger data, attempts, cancellation, retry, and approvals cannot be accessed by presenting a different body or query project id. Webhook ingress remains on its path-token and webhook-secret scheme, with dashboard host and browser-origin protections still enforced. +The foundation page in [Node Flow Foundation](/docs/architecture-node-flow-foundation) lists the low-level contracts. This page describes the end-to-end architecture and runtime expectations for developers and specialist agents. -## Implemented runtime nodes +## Data Model -| Type | Execution | +Node-flow persistence is owned by `NodeFlowRepository` and stored in SQLite: + +| Table | Purpose | +| --- | --- | +| `node_flows` | Current project-scoped flow record: id, project id, title, description, normalized `graph_json`, current version, and timestamps. | +| `node_flow_versions` | Immutable edit snapshots written on create and every update. | +| `node_flow_publications` | Immutable executable graph and execution-policy snapshots selected by pinned or latest-published runs. | +| `node_flow_agent_skills` | Agent attachment table keyed by flow and agent preset. It stores the skill display name and description used when exposing the flow as a repeatable agent capability. | +| `node_flow_runs` | Flow run records with status, version, trigger type, redacted trigger payload, redacted input/output, error message, timestamps, and optional `execution_invocation_id`. | +| `node_flow_node_runs` | Per-node run records with status, node id, redacted input/output, error message, timestamps, and optional `execution_invocation_id`. | +| `node_flow_node_attempts` | Numbered attempts with executor/invocation identity, artifact digest, credential ids, redacted payloads, failure class, and retry decision. | + +All graphs, widget schemas, run inputs, outputs, and trigger payloads are stored as JSON text and hydrated into typed contracts at the repository boundary. Flow, version, run, and attachment records belong to a project. Agent attachment operations verify that the target agent preset belongs to the same project as the flow. + +Authenticated dashboard requests resolve project ownership from the persisted flow, run, or approval record before role and project authorization. This applies to ID-only draft, publication, comparison, rollback, attachment, webhook-configuration, run debugger, attempt, cancellation, retry, and approval routes; a caller-supplied body or query project id is not treated as proof of ownership. Webhook ingress is the exception to dashboard bearer authentication and continues to use its path-token and webhook-secret scheme, while dashboard host and browser-origin protections still apply. + +## Graph Contract + +The shared contract lives in `src/contracts/node-flow-types.ts`. + +A `NodeFlowGraph` contains: + +- `nodes`: stable node ids, string node `type`, title, optional description, optional position, optional `widgetSchema`, and JSON `data`. +- `edges`: directed links from `fromNodeId` to `toNodeId`. +- `inputSchema`: optional graph-level widget schema for run input. +- `metadata`: optional JSON object for non-secret descriptive data. + +Validation is owned by `src/domain/node-flows/node-flow-validation.ts`. It normalizes ids, labels, positions, widget defaults, and graph shape; rejects missing node/edge arrays; rejects duplicate node ids; rejects edges that point at missing nodes; requires at least one node; and rejects cycles. Widget validation supports `text`, `textarea`, `number`, `boolean`, `select`, `json`, `secretRef`, and `keyValue` fields. + +Migration and validation treat persisted Graph v1 and canonical Graph v2 as untrusted input. Malformed collection members are rejected at their original index, such as `nodes[1].ports[0]` or `edges[2]`, while structurally valid siblings remain available to the rest of normalization. Definition references, credential bindings, capabilities, policies, port and graph schemas, and JSON metadata emit deterministic field-level issues instead of throwing. Revalidating the same graph produces the same ordered issue list. + +Validation requires every node's type/version reference to resolve through the registry and rejects unknown definitions. Runtime execution then dispatches according to the registered definition's executable state and execution kind; a planning concept is not runnable merely because it has a string type. + +The dashboard uses the same backend-owned Graph v2 record as the runtime. The selected project controls library loading; no project means no flow, credential, publication, or run requests. The registry list endpoint returns flat palette summaries, while the node-type detail endpoint returns a complete `NodeDefinitionManifest` with nested `ui`, schemas, policies, documentation, and deprecation metadata. The inspector consumes that full manifest. Draft saves use optimistic `draftRevision` checks and surface conflicts without overwriting the newer record. + +`dashboard/src/v2/lib/nodes-canvas-state.ts` remains only a compatibility and pure graph-state layer. Its legacy browser graph can be imported once into a project draft. The adapter translates `trigger`/`agent`/`task` into registered `input`/`set_fields`/`provider_prompt` nodes, remaps legacy handles to governed ports, and retains non-secret canvas metadata. Import failure is isolated from the normal library load; only a successful draft creation removes the old graph key and records the project marker. Browser storage is not the workflow source of truth. + +### Credential binding lifecycle + +Each versioned node definition is the slot-policy authority: every slot declares whether it is required, its allowed credential kinds, and all required capabilities. The picker lists project-visible metadata, then filters each candidate through secure-backend readiness, configured/active state, project access, kind, and capability compatibility. It never resolves a value. + +`NodeFlowNode.credentialBindings` is the only persisted binding source. Selecting, replacing, or unbinding a credential changes the matching `{ slot, credentialId }` entry in the complete canonical graph and saves with the current `draftRevision`. The dashboard adopts the returned graph and revision, then refreshes governed review. A `409`-style revision conflict refreshes the latest draft and requires a deliberate retry; it never replays a stale binding over sibling changes. + +Required unbound slots and any bound credential denied by backend readiness, configuration, active status, project access, allowed kind, or required capabilities block publication. Optional unbound slots do not. Runtime revalidates the immutable publication and repeats the same policy immediately before direct credential-ID resolution, so revocation, restriction, rotation/rebinding races, missing custody, or incompatible policy deny the node attempt rather than injecting stale plaintext. Graph, review, publication, MCP, and dashboard payloads contain IDs and non-secret policy metadata only. + +## Runtime + +`NodeFlowRuntimeService.runFlow(projectId, flowId, input, options)` resolves an explicit pinned or latest-published snapshot, revalidates that immutable graph, claims a durable lease, and executes nodes in topological order. See [Node Flow Durable Execution](/docs/architecture-node-flow-durable-execution) for queue, retry, lease, recovery, quota, and redaction guarantees. + +Runtime-supported node types are: + +| Node type | Behavior | | --- | --- | -| `input` | Emits run input. | -| `set_fields` | Transforms object fields. | -| `template` | Renders text templates. | -| `provider_prompt` | Invokes a configured CLI provider. | -| `http_request` | Performs a bounded HTTP/HTTPS request. | -| `condition`, `switch` | Selects one explicit output branch and persists unselected branches as skipped. | -| `foreach`, `merge` | Bounds item fan-out and combines active inputs with an explicit strategy. | -| `delay`, `approval` | Waits with cancellation or persists an operator decision gate. | -| `email_draft`, `email_send` | Produces a draft, or sends only after approval through the idempotent outbox. | -| `execute_subflow` | Executes a same-project published flow with recursion bounds. | -| `webhook_trigger` | Emits secret-authenticated webhook input. | -| `output` | Selects the result. | - -These are the executable definitions. Other custom palette concepts remain non-executable until a versioned handler is registered. Graph v1 migration preserves the legacy snapshot and appends deterministic v2. Both migrated v1 and canonical v2 graphs are validated as untrusted input: malformed nested members fail closed with stable paths at their original array indices, valid siblings are retained where safe, and repeated validation returns the same ordered issues instead of throwing. - -The separate browser-canvas compatibility bridge translates legacy `trigger`, `agent`, and `task` kinds into registered `input`, `set_fields`, and `provider_prompt` nodes and remaps their handles before draft creation. Import failure is reported without blocking the selected project's existing flow library. - -Execution uses immutable publications rather than the mutable editor row. Runs select a pinned publication or the latest published version, then use durable queue claims, leases, bounded quotas, timeout/cancellation propagation, and numbered retry attempts. Expired external attempts with unknown outcomes require operator attention and are not silently replayed. See [Node Flow Durable Execution](/docs/architecture-node-flow-durable-execution). - -HTTP and future custom-node requests share one HTTPS-first egress policy with URL-credential rejection, DNS and redirect revalidation, private-network and metadata blocking, host/port allowlists, bounded content, retries, timeouts, and rate limits. See [Built-ins and External-Effect Security](/docs/architecture-node-flow-builtins-and-security). +| `input` | Emits the run input object. | +| `set_fields` | Merges upstream object output with configured `fields` or `values`; set `replace: true` to ignore upstream output. | +| `template` | Renders `template` or `prompt` into `outputKey` (default `text`). | +| `provider_prompt` | Renders a prompt and calls an existing CLI provider configuration through `ProviderExecutionService`. | +| `http_request` | Performs bounded HTTP/HTTPS requests with method, URL, headers, query, body, timeout, and optional JSON path extraction. | +| `condition`, `switch` | Select one explicit output branch; non-selected branches are persisted as skipped. | +| `foreach` | Validates and emits a bounded item list. | +| `merge` | Combines active inputs with `object`, `array`, or `first` strategy. | +| `delay` | Waits for a bounded cancellable duration. | +| `approval` | Persists an operator decision gate. | +| `email_draft`, `email_send` | Creates a draft, or sends only after approval through the idempotent outbox. | +| `execute_subflow` | Executes a same-project published subflow with recursion bounds. | +| `webhook_trigger` | Emits authenticated webhook input. | +| `output` | Selects final output from a path, configured fields, or upstream output. | + +Template interpolation reads from `{{ input.path }}` and `{{ nodes.nodeId.path }}`. Node config is built from widget defaults, node `data`, and optional `data.values`, with later values overriding defaults. + +Provider prompt nodes require a configured CLI provider. HTTP nodes require HTTPS unless HTTP is explicitly enabled and support `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `HEAD`. Requests pass through the shared SSRF, redirect, DNS, response-size, content-type, retry, timeout, and rate-limit policy described in [Node Flow Built-ins and External-Effect Security](/docs/architecture-node-flow-builtins-and-security). + +## Invocation Tracking + +Node flows use `execution_invocations` as the observable runtime surface: + +- each flow run creates a parent invocation with `type: "node_flow"` +- externally observable node steps create invocation rows with `type: "node_flow_node"` +- `node_flow_runs.execution_invocation_id` links the run record to the parent invocation +- `node_flow_node_runs.execution_invocation_id` links provider and HTTP node rows to their invocation record + +Only `provider_prompt` and `http_request` nodes currently create `node_flow_node` invocation rows. Deterministic local nodes still create `node_flow_node_runs` rows, but do not create extra execution invocations. + +Provider prompt nodes pass an existing invocation id into `ProviderExecutionService` and disable prompt/assistant transcript capture for raw prompt content. HTTP nodes append a redacted request summary. Flow run inputs, outputs, node input/output payloads, trigger payloads, graph data, and MCP responses redact secret-shaped keys such as `apiKey`, `authorization`, `cookie`, `password`, `secret`, and `token`. + +## Failure Semantics + +A failed node fails the flow and persists skipped records for downstream descendants by default. If a node has `data.continueOnError = true`, the failed node records `{ "error": "" }` as output and downstream nodes may continue. + +Cancellation records cancelled node rows for the current and remaining nodes. At completion, the parent invocation is updated to `completed`, `failed`, or `cancelled` to match the flow outcome. + +## Scheduling + +Scheduler entries with `targetType: "node_flow"` persist an explicit `versionSelection`: pinned schedules continue to execute version N after N+1 is published, while latest-published schedules resolve the newest publication at dispatch time. Legacy `flowVersion` values normalize to pinned selection and are executable semantics, not audit-only metadata. Ownership is validated when entries are created or updated and again before due-run execution. + +Due runs call `NodeFlowRuntimeService.runFlow` with `triggerType = "scheduler"` and trigger payload metadata for the scheduler entry id, scheduled occurrence time, target type, and persisted flow version when present. Node-flow schedules advance only when `runFlow` returns a run status of `succeeded`. Returned `failed` or `cancelled` runs mark the scheduler entry `failed` with the run error and still count the attempted occurrence in `lastRunAt` and `runCount`; runtime startup rejections mark failure without creating a false successful schedule run. + +## Agent Skill Attachment + +`node_flow_agent_skills` exposes a saved flow to an agent preset as a repeatable skill. Attachment stores `flow_id`, `project_id`, `agent_preset_id`, `skill_name`, `description`, and timestamps. + +Attachment does not copy the graph into the agent preset, and detach removes only the binding. The flow remains project-owned and can still be edited, scheduled, manually run, or attached to other agents. + +## Agent Design Guidance + +Specialist agents designing node flows should adapt workflows to Code UX instead of copying n8n or another tool one node at a time. + +Use these rules: + +- Model the repeatable outcome first, then choose the smallest Code UX graph that captures the inputs, provider calls, HTTP calls, transformations, and final output. +- Use the governed executable built-ins listed in the runtime table when the workflow needs to execute today. A registered custom definition is executable only when its validated immutable artifact and custom runtime are available. Treat unknown types, legacy browser-only kinds, and non-executable manifests as planned or unavailable definitions. +- Put operator-editable values in `inputSchema` or per-node `widgetSchema` fields. Do not bury frequently changed values in opaque JSON blobs. +- Use `secretRef` widgets and secret reference strings for credentials. Do not place raw API keys, bearer tokens, cookies, passwords, or private headers in graph metadata, node data, widget defaults, run input, or examples. +- Validate every node field before saving: required prompt/template/url fields, finite numeric limits, supported HTTP method, JSON object input, and select defaults that match options. +- Keep flows deterministic and rerunnable. Avoid hidden dependence on local time, ambient chat state, or one-off sprint context unless it is explicitly passed as JSON input. +- Preserve inspection value. Name nodes for the operation they perform, keep edges acyclic, and make the output node return the artifact another operator or agent will actually consume. + +## Graph v2 contract and migration + +Graph v2 is the single workflow model used by backend, MCP, runtime, and dashboard. It adds `schemaVersion: 2`, stable definition references, typed ports and flow schemas, credential-id bindings, retry and timeout policies, capability and side-effect metadata, disabled state, and optional immutable publication metadata. Plaintext credentials, secret-shaped fields, generated source, and custom code are not valid graph data. + +The executable registry contains the original deterministic/provider/HTTP nodes plus `condition`, `switch`, `foreach`, `merge`, `delay`, `approval`, `email_draft`, `email_send`, `execute_subflow`, and `webhook_trigger`. Unregistered custom types remain non-executable. + +Backend Graph v1 migration retains the exact prior version and appends deterministic v2. Invalid legacy members are carried across the migration boundary so validation can report their original paths rather than silently dropping them. Browser canvas v1 migration returns the untouched legacy snapshot separately from the normalized graph. + +## Dashboard and security prerequisites + +Outside development builds, the Nodes workspace is enabled only when `VITE_CODEUX_FEATURE_NODES`, `VITE_CODEUX_NODE_FLOW_BACKEND`, and `VITE_CODEUX_AUTOMATION_SECURITY` are true. These flags expose the surface; they do not replace runtime dependencies. Provider execution, credential resolution, outbound HTTP, approval-gated email, webhook ingress, and custom-node containers each require their corresponding configured service and security policy. + +The dashboard exposes credential binding ids, declared kinds, scopes, and status metadata only. Resolved values stay at the credential broker/runtime boundary and are redacted before invocation messages, attempt payloads, diagnostics, and route responses are persisted or rendered. Policy review must surface requested capabilities and external side effects before publication, and publication requires a valid current draft with all required bindings satisfied. diff --git a/docs-web/content/docs/operations-credential-security.mdx b/docs-web/content/docs/operations-credential-security.mdx index 41fa50e335..48665eb98f 100644 --- a/docs-web/content/docs/operations-credential-security.mdx +++ b/docs-web/content/docs/operations-credential-security.mdx @@ -1,50 +1,76 @@ # Automation Credential Security -Code UX resolves canonical node credential IDs and named project binding keys through the credential broker. Stored values are not exposed to nodes, dashboard reads, MCP payloads, agent context, run inspection records, or access audits. +Code UX stores automation credentials through a broker rather than exposing secret values to node definitions, dashboard reads, MCP payloads, agent context, or run inspection records. Canonical node bindings reference credential metadata by ID; only the broker can resolve the value at execution time after project and capability checks. Named project binding keys use the same broker for other automation consumers. ## Scope and policy -- Project credentials are owned by one project. -- Global credentials require an explicit project allowlist and retain the configuring project as their management owner. Other allowlisted projects may bind and resolve the credential but cannot mutate it. -- The credential kind must be allowed, and both the binding and credential must approve every declared capability before one secret read. +- Project credentials can be managed only through their owning project. +- Global credentials are opt-in and require an explicit project allowlist containing the configuring project. The configuring project remains the credential's management owner after promotion; other allowlisted projects may bind and resolve it but cannot rotate, replace, revoke, promote, or restrict it. +- Resolution succeeds only when the credential kind is allowed and both the credential and binding approve every declared capability. Authorization is completed before the broker performs its single secret read. - Revoked, unavailable, missing, cross-project, or insufficiently capable credentials fail closed. Node-flow definition slots explicitly declare required/optional state, allowed kinds, and required capabilities. Draft review and every publication path use the broker's metadata-only compatibility assessment; runtime sends the same declaration to direct credential-id resolution immediately before execution. Graph `credentialBindings` are canonical. The legacy credential-request endpoint records no binding and identifies its result as non-persistent. -Create, rotate, and replace requests are write-only. API responses contain configuration and status metadata but never stored values. +The dashboard accepts secret values only on create, rotate, and replace requests. Responses contain configuration, scope, status, key-version, and validation metadata but never stored values. Access-event rows contain identifiers, binding keys, capabilities, outcomes, and denial reasons; they never contain secret material. -Create requests explicitly declare kind, scope, capabilities, and an allowlist (empty for project credentials). Runtime validation bounds names, identifiers, capabilities, list counts, and secret size (64 KiB UTF-8). Malformed arrays, unknown mutation fields, and control characters are rejected rather than coerced. +The Settings Integrations catalog exposes this broker as its first standard card. The card derives unavailable, ready/unconfigured, and configured states from backend health and project-visible metadata; **Manage** opens a project-aware detail view without rendering a secret, request body, or raw server error. Allowlisted non-owner projects can understand and use compatible global credentials but see management actions disabled. -Every lifecycle mutation includes `expectedVersion`. The only mutable descriptive field is the bounded name; kind and management ownership remain immutable. Restrictions may remove allowlisted projects or capabilities but cannot add them. Project-to-global promotion is the explicit scope expansion and requires managing-project authority, `confirmScopeExpansion: true`, the current version, and an allowlist of existing projects that retains the managing project. Current-version repeated revocation is idempotent; stale versions conflict. +Create controls require deliberate capability selection and explicit project or global scope. Global allowlists retain the management owner, and scope-expanding creation or promotion is confirmed. Rename, test, rotation/replacement, restriction, promotion, and revocation report typed inline status, disable overlapping actions, and refresh after stale-version conflicts. Destructive and scope-expanding actions use keyboard-operable confirmation dialogs with focus restoration. + +All create, rotate, and replacement fields are controlled write-only inputs. They are never hydrated from metadata and are cleared after every submission outcome, project change, and component teardown. Credential metadata drafts and browser stores do not receive secret values. + +Management inputs are validated at runtime rather than trusted from TypeScript types. Create requests must explicitly declare kind, scope, capabilities, and an allowlist (an empty array for project credentials). Names, kinds, binding keys, project ids, capabilities, and list counts are bounded; malformed arrays, unknown mutation fields, and control characters are rejected instead of being silently coerced. A stored value is limited to 64 KiB of UTF-8 data. Global allowlists must explicitly retain the management owner. + +Every lifecycle mutation carries `expectedVersion`. Successful name updates, validation tests, rotations/replacements, promotions, restrictions, and first-time revocations increment the version. A repeated revoke against an already-revoked credential at its current version is an idempotent no-op; stale requests return a conflict. Metadata updates may change only the bounded display name, so kind and management ownership remain immutable. + +Restriction is monotonic: it may remove allowlisted projects or capabilities but cannot add either. Project-to-global promotion is the explicit scope expansion and requires the managing project, a current version, `confirmScopeExpansion: true`, an allowlist containing the managing project, and project IDs that already exist. ## Runtime redaction boundary -Node-flow credentials exist in plaintext only for the active node attempt. Exact resolved values are replaced with `[REDACTED]` before provider responses, HTTP bodies, retry errors, external-effect payloads, diagnostics, invocation messages, attempts, node outputs, or run summaries are stored. Credential IDs and non-secret metadata remain available for auditability. +Node-flow execution resolves credential values only for the active node attempt. Before any provider response, HTTP body, retry error, external-effect payload, diagnostic, invocation message, attempt, node output, or run summary is persisted, the runtime replaces exact resolved values with `[REDACTED]` in addition to masking secret-shaped keys. Credential IDs and non-secret metadata remain available for audit and attempt correlation. -The same redactor protects provider activity and raw usage telemetry. Temporary credential references are cleared after the attempt and are never logged as redaction input. Custom-node outputs, stderr logs, and diagnostics follow the same rule. +Provider activity persistence uses the same invocation-scoped redactor, including raw usage telemetry and provider session identifiers. Temporary credential references are cleared after each attempt and are never included in redaction logs or diagnostics. Custom-node containers apply the equivalent policy to structured output, stderr logs, and diagnostics before returning control to the flow runtime. -Authorization is rechecked after decryption. Concurrent revocation, rotation, restriction, promotion, or rebinding clears the plaintext buffer and causes a retry or denial instead of returning stale access. +Resolution authorization is checked both before and after decryption. If a credential is revoked, rotated, restricted, promoted, or rebound while a read is in flight, the plaintext buffer is cleared and the broker denies or retries against the current version; stale authorization is never returned to the caller. ## Encryption and key custody -The SQLite secret store uses AES-256-GCM envelope encryption with a unique data key, payload nonce, and key-wrapping nonce for every write. Credential ownership and workspace context are authenticated. SQLite stores ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions—not root keys. +The SQLite secret store uses AES-256-GCM envelope encryption. Each write generates a unique 256-bit data key, payload nonce, and key-wrapping nonce. Credential ownership and workspace context are authenticated as additional data. SQLite stores only ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions. + +Root keys are never stored in SQLite or a project checkout. The normal loopback dashboard automatically provisions one raw 32-byte root key at `~/.code-ux/security/credential-root.key`. Its dedicated parent directory is `0700` and the regular file is `0600`. Creation uses an exclusive atomic install, durable filesystem synchronization where supported, and concurrent startup convergence so restarts recover the identical key. Before creation or access, every custody-path component from the Code UX home through the key parent is inspected without following symbolic links; a symbolic-link or non-directory ancestor fails closed before a redirected key can be provisioned. Existing symbolic links, non-files, malformed keys, permissive modes, or unexpected ownership are never repaired automatically; credential operations fail closed with metadata-only setup guidance. -The trusted loopback dashboard automatically provisions one raw 32-byte root key at `~/.code-ux/security/credential-root.key`, with a `0700` parent and `0600` regular file. Provisioning is exclusive, atomic, durable where filesystem synchronization is supported, and safe across concurrent startup. Every custody-path component from the Code UX home through the key parent is inspected without following symbolic links, so a symbolic-link or non-directory ancestor fails closed before a redirected key can be provisioned. Symbolic links, non-files, malformed content, permissive modes, and unexpected ownership fail closed and are not repaired automatically. +Automatic local-file custody is limited to the non-server dashboard with local authentication, loopback binding, and remote credential management disabled. Electron's process provider remains first priority and continues to use OS `safeStorage`. Explicit `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms` configuration takes priority over automatic custody; setting `CODE_UX_CREDENTIAL_KEY_FILE` alone remains compatible with the mounted-file provider. Unknown values and an explicit `local-file` selection are rejected. Dashboard-disabled headless operation, server mode, authenticated dashboards, non-loopback bindings, and remote credential-management deployments do not auto-provision a local key. -Automatic local-file custody is disabled for server mode, dashboard-disabled headless operation, authenticated or non-loopback dashboards, and remote credential management. Electron remains first priority and persists only an OS-protected blob. Explicit `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms` configuration takes priority; `CODE_UX_CREDENTIAL_KEY_FILE` alone remains a compatible mounted-file selection. Unknown values and explicit `local-file` selection are rejected. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback. +For mounted-file custody, `CODE_UX_CREDENTIAL_KEY_FILE` identifies a regular, owner-only mounted file whose contents are an exact base64 or hexadecimal encoding of 32 bytes. Oversized or permissively decodable key files are rejected. The environment variable contains a path, not key material. Keep the mount readable only by the Code UX process and outside the project workspace. + +Electron serializes first-use root-key creation, persists only the OS-protected blob through an atomic owner-only file replacement, and refuses credential operations when `safeStorage` is unavailable. Vault and KMS adapters validate 32-byte caller-owned key material and report the active key id/version in health results. No provider silently falls back to plaintext or an insecure locally derived key. + +| Deployment boundary | Root-key custody | Provisioning behavior | +| --- | --- | --- | +| Normal CLI dashboard on loopback with local authentication | Owner-only file under the user-home Code UX security directory | Automatically created on first use and reused after restart. A normal local dashboard user does not mount or configure a key file. | +| Electron desktop | Operating-system `safeStorage` | Automatically creates and persists only the OS-protected blob; unavailable `safeStorage` blocks credential operations. | +| Dashboard-disabled headless, server mode, authenticated dashboard, non-loopback binding, or remote credential management | Explicit mounted file, Vault, or KMS provider | Never auto-provisions local custody. Setup and recovery fail closed until the configured provider reports available, secure key identity and version metadata. | ## Recovery and rotation -Back up root keys separately from `app.db`; the database alone cannot recover credentials. Local dashboard backups must include `~/.code-ux/security/credential-root.key` with owner-only handling. Creation, rotation/replacement, and promotion commit ciphertext and metadata atomically. Version compare-and-swap protects every lifecycle mutation and permits only one overlapping value change to commit. Revocation also wins against an in-flight resolution while retaining audit metadata. +Back up root keys independently from `app.db`. For the normal local dashboard, back up `~/.code-ux/security/credential-root.key` while preserving owner-only handling; for external providers, retain every referenced key version. Losing a required key version makes its ciphertext unrecoverable by design. Restoring only SQLite is insufficient. + +Credential creation commits metadata and its first envelope in one SQLite transaction. Rotation/replacement and promotion likewise commit the new envelope, metadata, version, and rotation record atomically. Compare-and-swap guards apply to every lifecycle mutation so losing callers must refresh metadata and retry instead of overwriting newer state. Root-key providers must retain old key IDs and versions until envelopes are rewrapped. Revocation wins against in-flight resolutions and preserves audit metadata. -Lifecycle success and denial audits carry correlation IDs, credential IDs, and policy metadata only. Validation records `valid`, `invalid`, or `unavailable` without exposing tested values or cryptographic internals. +Lifecycle successes and denials emit correlation-aware automation audit records containing credential IDs and policy metadata only. Validation updates report `valid`, `invalid`, or `unavailable` without including tested values or low-level cryptographic errors. Custom dashboards use a stricter metadata-only consumer boundary. Dedicated slot declarations define allowed kinds and required capabilities, while separate draft and immutable-revision binding columns store credential IDs. Binding review delegates to the broker's compatibility assessment and never resolves plaintext. Required or invalid bindings stop validation before workspace creation and are rechecked before publication. Credential values and binding IDs are excluded from generated dashboard artifacts, Docker configuration, validation output, generic REST/MCP responses, and iframe messages; only the dedicated binding-management response may expose IDs with non-secret metadata. -Legacy global records use their first valid allowlisted project as the migrated management owner; verify that owner before expanding an old global allowlist. +Existing global credentials created before management ownership was stored are migrated with their first valid allowlisted project as the management owner. Operators should verify that owner before expanding a legacy global credential's allowlist. + +## API surface + +Project-scoped routes live under `/api/projects/:projectId/credentials`. Supported operations are create, bounded-name update (`PATCH /:credentialId`), bind, metadata-only compatibility assessment, test, rotate, replace, revoke, promote, and restrict. Compatibility evaluates key-backend readiness, configuration, active status, project access, allowed kinds, and all required capabilities without resolving plaintext. A backend is ready only when it is available and secure and reports both a non-empty key ID and a key version; missing key identity metadata produces the stable `backend_unavailable` compatibility issue. List, compatibility, health, and mutation responses return metadata or policy results only. Existing dashboard authentication and remote credential-management guards apply before these routes. -## Dashboard API +Runtime validation failures return `400`, project/management denials return `403`, compare-and-swap conflicts return `409`, invalid encrypted state returns `422`, and unavailable key custody returns an actionable `503` response. -Credential management uses project-scoped dashboard routes. The API includes create, bounded-name update, bind, metadata-only compatibility assessment, test, rotate, replace, revoke, confirmed promotion, and monotonic restriction. Compatibility checks backend readiness, configured/active state, project access, allowed kinds, and all required capabilities without reading plaintext. Backend readiness requires an available, secure backend with a non-empty key ID and a reported key version; missing identity metadata produces `backend_unavailable`. List, health, compatibility, and mutation responses never contain secret values; secrets are accepted only by create, rotate, and replace operations. +## Troubleshooting without disclosure -Validation failures return `400`, project/management denials return `403`, concurrent-write conflicts return `409`, invalid encrypted state returns `422`, and unavailable key custody returns `503` with a safe recovery message. +- If custody is unavailable, inspect the metadata-only credential health or readiness result and the configured provider name. For the normal loopback dashboard, verify ownership, file type, and owner-only modes on the existing Code UX security path; for Electron, restore OS `safeStorage`; for headless or remote operation, restore the configured mount, Vault, or KMS version. Never paste, print, regenerate over, or move root-key material into a repository to diagnose the failure. +- If a mutation reports a stale `expectedVersion`, refresh credential metadata and review the newer scope, capabilities, validation state, and status before retrying. Do not reuse the rejected request blindly and do not bypass the comparison check. +- If encrypted rows exist but their key version is unavailable, restore the exact retained provider version before starting runners. Replacing it with a new key does not decrypt old envelopes; restore from the independent custody backup or recover the affected credential through the supported replacement workflow after the runtime is ready. diff --git a/docs-web/content/docs/operations-server-mode.mdx b/docs-web/content/docs/operations-server-mode.mdx index 27ba8c0fa4..99331fbedd 100644 --- a/docs-web/content/docs/operations-server-mode.mdx +++ b/docs-web/content/docs/operations-server-mode.mdx @@ -1,29 +1,249 @@ -# Authenticated Headless Server Mode +# Secure Headless Server Mode -Code UX separates MCP bearer access from the authenticated dashboard administrative API. Remote dashboard/API deployments must use digest-backed service identities or terminate OIDC at a trusted reverse proxy; loopback desktop operation remains a trusted local boundary. +Server mode runs Code UX as an authenticated MCP HTTP control plane without binding the dashboard UI, dashboard REST routes, dashboard realtime websocket, terminal websocket, or static dashboard assets. Use it for headless hosts, CI-adjacent automation, and cluster worker control planes where clients connect over Streamable HTTP instead of launching Code UX over stdio. -## Identity and authorization +Server mode is different from ordinary `--headless` mode: -Set `CODE_UX_DASHBOARD_AUTH_MODE=service_token` and provide `CODE_UX_SERVICE_IDENTITIES_JSON` entries containing `id`, `displayName`, SHA-256 `tokenSha256`, `roles`, explicit `projectIds`, and `enabled`. Workers send the bearer through `CODE_UX_WORKER_AUTH_TOKEN` and may assert the matching identity with `CODE_UX_WORKER_SERVICE_ID`. +| Mode | Dashboard | MCP HTTP | Token behavior | +| --- | --- | --- | --- | +| Default dashboard mode | Enabled | Enabled by default | Uses an explicit token or the generated user token in `~/.code-ux/security.json`. | +| `--headless` / `--no-dashboard` | Disabled | Uses normal MCP HTTP enablement rules | Preserves local-development behavior and can use the generated user token when HTTP is enabled. | +| `--server-mode` / `CODE_UX_SERVER_MODE=true` | Disabled | Enabled by default | Requires an explicit MCP HTTP bearer token with at least 32 bearer-safe characters. | -Alternatively, set `CODE_UX_DASHBOARD_AUTH_MODE=trusted_proxy`, configure `CODE_UX_TRUSTED_PROXY_SECRET`, terminate/validate OIDC at the proxy, strip client identity headers, and inject trusted principal, role, and project headers. Authenticated remote traffic requires TLS (`X-Forwarded-Proto: https`) unless insecure HTTP is explicitly enabled for an isolated test. +## Threat Model -Roles are `credential_admin`, `automation_author`, `automation_publisher`, `automation_runner`, and `viewer`. Credential routes additionally require `CODE_UX_REMOTE_CREDENTIAL_MANAGEMENT=true`; enabling it without a healthy secure key provider makes readiness fail. Host/origin checks, no-store responses, and administrative rate limits remain active. +MCP bearer access remains a runtime-wide control-plane identity. The dashboard administrative API has a separate authenticated-headless boundary with project-scoped roles; do not treat an MCP bearer as a dashboard service identity. -The `credential_admin` role can still read administrative readiness, audit export, and SLO metrics while remote credential management is disabled. The feature flag gates credential-management and credential-health routes only. +## Authenticated Dashboard API -## Probes, audit, and SLOs +Remote dashboard/API operation is fail-closed. Setting a non-loopback `DASHBOARD_HOST` without an explicit authentication mode defaults the API to `service_token`, so unconfigured callers receive `401`/`403` instead of inheriting desktop access. Loopback desktop mode remains `local`. -`/health` is liveness. `/ready` also checks credential-key recovery, the audit store, and distributed-runner identities and returns `503` when required components are unavailable. If encrypted credential rows exist and their key cannot be recovered, startup aborts before listeners bind. Server mode never auto-provisions local-file custody; configure `mounted-key-file`, Vault, or KMS explicitly. +Choose one boundary: -Authenticated operators can use `/api/admin/readiness`, `/api/admin/audit/export` (redacted NDJSON), and `/api/admin/metrics/slo`. Audit covers management calls, credential access, runs, attempts, approvals, and outbox delivery with correlation ids. +- `CODE_UX_DASHBOARD_AUTH_MODE=service_token`: define `CODE_UX_SERVICE_IDENTITIES_JSON` as an array of identities with `id`, `displayName`, a lowercase SHA-256 `tokenSha256`, `roles`, `projectIds`, and `enabled`. Workers may send the matching id with `--service-identity-id` or `CODE_UX_WORKER_SERVICE_ID`; the bearer remains in `CODE_UX_WORKER_AUTH_TOKEN`. +- `CODE_UX_DASHBOARD_AUTH_MODE=trusted_proxy`: terminate OIDC at a trusted proxy, set `CODE_UX_TRUSTED_PROXY_SECRET`, and have the proxy overwrite `X-Code-UX-Proxy-Secret`, `X-Code-UX-Principal-Id`, `X-Code-UX-Roles`, `X-Code-UX-Project-Ids`, and optional name/kind headers. Never forward client-supplied copies. -Baseline alerts: readiness not ready for five minutes, management 5xx above 1% or p95 above one second for ten minutes, repeated lease expiry, credential-denial spikes, outbox failure backlog, or any secret/audit check failure. Target zero unauthorized project grants, secret disclosures, and duplicate side effects. +Roles are `credential_admin`, `automation_author`, `automation_publisher`, `automation_runner`, and `viewer`. Project ids are explicit; `*` is an operator-only all-project grant. Credential routes additionally require `CODE_UX_REMOTE_CREDENTIAL_MANAGEMENT=true`. Enabling that flag without a healthy secure key provider makes readiness fail. -## Backup and recovery +The `credential_admin` role can read `/api/admin/readiness`, `/api/admin/audit/export`, and `/api/admin/metrics/slo` even when remote credential management is disabled. The feature flag gates credential creation, binding, testing, rotation, replacement, revocation, promotion, restriction, and credential-health routes; it does not disable operational readiness, audit, or SLO inspection. -Back up SQLite with WAL consistency, settings, project `.code-ux/` state, and every referenced external key version. Restore keys before databases, keep runner admission disabled, require `/ready`, then reconcile leases, approvals, audit continuity, and outbox counts. Never back up plaintext service tokens beside their digests. +TLS is assumed at the reverse proxy. Authenticated remote requests must arrive with HTTPS or a trusted `X-Forwarded-Proto: https`; `CODE_UX_ALLOW_INSECURE_HTTP=true` is limited to isolated test networks. Same-origin browser checks, no-store headers, host validation, and a 600-request/minute administrative API limiter remain active. Webhook and provider-ingress endpoints retain their dedicated authentication schemes. -Rotate service identities by overlapping new/old digests until runners authenticate with the new token. Rotate credential values through the broker so graph bindings retain ids and resolve the next version. Retain old KMS/Vault versions until envelope rewrap and restore drills pass. +Example identity generation (the JSON stores only the digest): -Rollback creates and publishes a new draft from an earlier immutable version; in-flight runs stay pinned. Recovery requeues only known-safe pre-invocation work and leaves uncertain external outcomes for attention. OIDC validation and Vault/KMS client integration remain deployment-host responsibilities, and MCP bearer authority remains broader than dashboard roles. +```bash +token="$(openssl rand -base64 48 | tr -d '\n')" +digest="$(printf '%s' "$token" | sha256sum | cut -d' ' -f1)" +# Put $token in the runner secret manager and $digest in CODE_UX_SERVICE_IDENTITIES_JSON. +``` + +Use server mode when: + +- the dashboard must not be reachable from the host +- MCP clients or workers need a stable HTTP endpoint +- a reverse proxy or private network boundary provides TLS and network admission +- operators can treat the bearer token as a secret with full runtime authority + +Do not expose the MCP HTTP listener directly to the public internet. The Node listener is HTTP; terminate HTTPS with a trusted reverse proxy, tunnel, service mesh, or load balancer when traffic leaves the host. + +## Startup + +Generate the token in the process environment or a secret manager. Do not paste real bearer values into shell history, logs, tickets, release notes, or documentation. + +```bash +export MCP_HTTP_AUTH_TOKEN="$(openssl rand -base64 48 | tr -d '\n')" + +codeux \ + --server-mode \ + --mcp-http-host 127.0.0.1 \ + --mcp-http-port 4445 \ + --mcp-http-path /mcp +``` + +For a cluster control plane behind a reverse proxy or private network interface: + +```bash +export CODE_UX_SERVER_MODE=true +export MCP_HTTP_AUTH_TOKEN="$(openssl rand -base64 48 | tr -d '\n')" +export MCP_HTTP_HOST=0.0.0.0 +export MCP_HTTP_PORT=4445 +export MCP_HTTP_PATH=/mcp +export MCP_HTTP_MAX_SESSIONS=500 +export MCP_HTTP_SESSION_TIMEOUT_MS=3600000 + +codeux +``` + +The legacy `mcp-https` names remain supported for compatibility: + +| Purpose | Preferred | Legacy-compatible | +| --- | --- | --- | +| Gateway enablement | `MCP_HTTP_ENABLED`, `--no-mcp-http` to disable outside server mode | `MCP_HTTPS_ENABLED`, `--no-mcp-https` to disable outside server mode | +| Gateway host | `MCP_HTTP_HOST`, `--mcp-http-host` | `MCP_HTTPS_HOST`, `--mcp-https-host` | +| Gateway port | `MCP_HTTP_PORT`, `--mcp-http-port` | `MCP_HTTPS_PORT`, `--mcp-https-port` | +| Gateway path | `MCP_HTTP_PATH`, `--mcp-http-path` | `MCP_HTTPS_PATH`, `--mcp-https-path` | +| Bearer token | `MCP_HTTP_AUTH_TOKEN`, `--mcp-http-auth-token` | `MCP_HTTPS_AUTH_TOKEN`, `--mcp-https-auth-token` | +| Session cap | `MCP_HTTP_MAX_SESSIONS`, `--mcp-http-max-sessions` | `MCP_HTTPS_MAX_SESSIONS`, `--mcp-https-max-sessions` | +| Idle timeout | `MCP_HTTP_SESSION_TIMEOUT_MS`, `--mcp-http-session-timeout-ms` | `MCP_HTTPS_SESSION_TIMEOUT_MS`, `--mcp-https-session-timeout-ms` | + +Server mode rejects startup when the explicit token is missing, empty, shorter than 32 characters, or contains characters outside the bearer-safe set. It does not fall back to the generated local user token. + +If `--server-mode` is combined with an explicit MCP HTTP disable flag, server mode still restores the MCP HTTP listener on the default MCP port because the server-mode contract requires authenticated remote MCP access while the dashboard stays disabled. + +## Health And Readiness + +The MCP HTTP listener serves probes without the dashboard server: + +```bash +curl --fail http://127.0.0.1:4445/health +curl --fail http://127.0.0.1:4445/ready +``` + +Use `/health` for process liveness. It only proves that the listener is up. + +Use `/ready` for runtime readiness. It reports whether the Code UX runtime finished the required startup path and can accept work. During startup, maintenance such as Docker cleanup, preview reconciliation, branch reaping, and recovery work can continue after the listener binds, so `/health` can pass before `/ready`. + +Do not include `Authorization` headers in probe logs. The probe endpoints do not require bearer credentials. + +`/ready` also reports `credentialKey`, `auditStore`, and `distributedRunner`. `/health` remains live during a key-provider outage, while `/ready` returns `503`. Startup aborts before dashboard or MCP binding when encrypted credential rows exist but their key provider cannot recover the wrapping key. Server mode never auto-provisions local-file custody. Select a provider with `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms`; mounted files use `CODE_UX_CREDENTIAL_KEY_FILE` and owner-only permissions. Vault/KMS modes require their host adapter to be configured and healthy. + +The same explicit-custody requirement applies to dashboard-disabled headless operation, authenticated dashboards, non-loopback dashboard bindings, and remote credential management. Only the trusted loopback local dashboard auto-provisions its owner-only user-home key; Electron uses OS `safeStorage`. Remote setup therefore fails closed rather than borrowing the local-dashboard key, deriving a key, or falling back to plaintext. Restore the configured mount or the exact Vault/KMS key version before enabling runners; do not copy root keys into SQLite, a project checkout, deployment logs, or diagnostic bundles. + +Authenticated operators can inspect `/api/admin/readiness`, export redacted NDJSON from `/api/admin/audit/export`, and sample `/api/admin/metrics/slo`. Audit rows include the correlation id, principal, project, action, outcome, and redacted metadata for management requests, credential access, runs, attempts, approvals, and outbox delivery. + +## Backup, Restore, Rotation, And Rollback + +Back up `~/.code-ux/app.db` with a SQLite-aware snapshot that includes/checkpoints WAL state, the settings database, project `.code-ux/` directories, and the external key-provider versions needed by every encrypted envelope. Never place plaintext service tokens or root keys in the database backup. Restore into an isolated host, restore keys first, run `/ready`, then enable runners. + +Rotate service tokens by adding the new digest, deploying the new runner secret, observing successful authenticated calls, and disabling the old identity entry. Rotate credential values through the credential rotation API; existing graph bindings keep the credential id and resolve the new version. Retain old KMS/Vault key versions until every envelope has been rewrapped and a restore drill succeeds. + +To roll back an automation, create a new draft from the earlier immutable version, review it, and publish it. In-flight runs remain pinned to their original publication. Stop runner admission before database recovery; after restore, startup recovery requeues only known-safe work and leaves unknown external outcomes in `attention_required`. + +## Baseline SLOs And Alerts + +Initial operator baselines are 99.9% authenticated management availability, p95 management latency below 500 ms, zero unauthorized project grants, zero secret disclosure, and zero duplicate outbox side effects. Alert when readiness is not ready for 5 minutes, management 5xx rate exceeds 1% for 10 minutes, p95 exceeds 1 second for 10 minutes, leases repeatedly expire, denied credential access spikes, outbox failures remain pending for 5 minutes, or any audit/secret scanning check fails. + +Local mode is intentionally a trusted loopback desktop boundary. Authenticated headless mode adds API RBAC, project scope, key readiness, durable audit, and service identities, but it is not a general multi-tenant identity platform: OIDC token validation belongs at the trusted proxy, Vault/KMS require host adapters, and MCP bearer authority remains broader than dashboard roles. + +## Client Connections + +MCP HTTP clients connect to the configured path with `Authorization: Bearer `. The first JSON-RPC request on a new Streamable HTTP session must be `initialize`; the server returns an `mcp-session-id` header that the client echoes on later calls. + +For a local CLI or dashboard-adjacent session that supports MCP HTTP, configure: + +- URL: `http://:4445/mcp` +- header name: `Authorization` +- header value: `Bearer ` + +Verify without exposing the token: + +```bash +curl --fail http://127.0.0.1:4445/health +curl --fail http://127.0.0.1:4445/ready +``` + +Then verify through the MCP client by listing tools or running a read-only management action such as listing projects. Do not use `curl -v`, shell tracing, or command transcripts that print the authorization header. + +If a local dashboard app is used only as an operator console for a separate server-mode instance, configure its MCP client entry to the server-mode URL and bearer header. The dashboard UI of the server-mode process itself remains unavailable by design. + +## Settings Synchronization + +Settings synchronization uses the `manage_settings` bundle actions: + +- `export_settings_bundle` +- `apply_settings_bundle` + +Bundles can include system, project, and sprint scopes. Metadata includes `schemaVersion: 1`, `exportedAt`, `includedScopes`, a SHA-256 `fingerprint` computed from a secret-redacted representation, and `containsSecrets`. + +Approved workflow: + +1. Export a redacted bundle from the source runtime. Export defaults to the `system` scope and redacts provider API keys, git tokens, issue-tracker tokens, login credentials, and other secret-bearing fields. +2. Review the bundle before moving it to the destination. Redacted placeholders are expected and must not be replaced in shared artifacts. +3. If project or sprint settings are required, include `scopes`, `projectIds`, and `sprintIds`. Sprint exports require the owning `projectId` so imports can normalize sprint overrides against the resolved project base. +4. Apply the bundle on the destination with `apply_settings_bundle`. The importer persists through `saveSystemSettings`, `saveProjectSettings`, and `saveSprintSettings`, so values follow the same sanitizer and override normalization as dashboard saves. +5. For partial rollout or rollback, pass `scopes` on apply to limit which bundle scopes are written. + +Secret-bearing exports and imports require the stateful settings approval flow: + +- `includeSecrets: true` on export returns secrets only after the first response asks for approval and the exact same request is repeated with `approval.confirmed: true`. +- A bundle marked `containsSecrets: true`, or one whose payload contains secret-bearing fields, is applied only after the same one-use approval flow. +- Approval is bound to the exact normalized payload, expires after 15 minutes, and is consumed after one successful execution. + +Rollback is another approved apply. Export a known-good bundle before changing a destination runtime, then apply that bundle back to the affected scopes if the rollout must be reverted. Do not rely on logs or chat transcripts as backups because redaction intentionally removes sensitive values. + +## Cluster Workers + +External workers connect to the server-mode MCP HTTP endpoint as control-plane clients. The worker process also starts a local `worker-host` runtime over stdio for execution on the worker machine. + +Start a worker with the shipped bin: + +```bash +codeux-worker \ + --server-url http://SERVER_HOST:4445/mcp \ + --auth-token "$CODE_UX_WORKER_AUTH_TOKEN" \ + --connection-key worker:build-node-01 \ + --display-name "Build node 01" \ + --project-id project-id +``` + +Equivalent environment variables: + +```bash +export CODE_UX_WORKER_SERVER_URL=http://SERVER_HOST:4445/mcp +export CODE_UX_WORKER_AUTH_TOKEN="$MCP_HTTP_AUTH_TOKEN" + +codeux-worker --connection-key worker:build-node-01 --project-id project-id +``` + +Worker config supports multi-project operation: + +- repeat `--project-id` to register eligible projects +- repeat `--active-project-id` to advertise active project focus +- use a stable `--connection-key` so reconnects update the existing registered endpoint +- set `--server-command`, repeated `--server-arg`, and `--server-cwd` only when the worker-local execution runtime needs a custom command + +Cluster behavior: + +- Registered workers are not license-capped. The active Streamable HTTP session cap defaults to 100 and can be raised for large clusters. +- Project assignments live in `project_worker_assignments`. A project can have one primary worker and any number of overflow workers. +- Active-session protection prevents runaway clients from allocating unlimited Streamable HTTP sessions. Raise `MCP_HTTP_MAX_SESSIONS` only to the capacity the server can actually operate. +- Heartbeats derive endpoint status. Stale or offline workers are excluded from new claims, and stale primary workers can be bypassed by eligible overflow workers. +- Dispatch safety depends on both `task_dispatches` and `execution_leases`. A worker must not start local execution unless the server returns a claim with a lease token. Heartbeats renew the lease while the task runs; expired leases can be claimed by another eligible worker. +- Multi-project workers claim only work for projects they are assigned to and advertise as active or eligible. + +## Token Rotation + +Safe rotation is a short planned restart unless a reverse proxy or secret manager can coordinate old/new tokens externally. + +1. Generate a new token in the secret manager. +2. Update client and worker secret references, but do not restart them yet. +3. Restart the server-mode process with the new token. +4. Restart or reconnect MCP clients and workers so they initialize new sessions with the new token. +5. Confirm `/ready` passes and clients can list tools or claim work. +6. Revoke the old token from the secret manager and remove it from local shells, process managers, and deployment manifests. + +Existing HTTP sessions authenticated with the previous token should be treated as invalid after server restart because Streamable HTTP sessions are in memory. Workers should reinitialize rather than attempting to reuse old `mcp-session-id` values. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| Startup fails with a server-mode token error | `--server-mode` or `CODE_UX_SERVER_MODE=true` is set without an explicit valid bearer token. | Set `MCP_HTTP_AUTH_TOKEN` or `MCP_HTTPS_AUTH_TOKEN`, or pass the matching CLI flag. Use at least 32 bearer-safe characters. | +| Startup fails when binding `0.0.0.0`, `::`, or a LAN address | MCP HTTP is reachable beyond loopback without an active token. | Configure an explicit bearer token and put TLS/auth network controls in front of the HTTP listener. | +| Dashboard URL is unavailable | Expected in server mode. | Use MCP HTTP clients and `/health` or `/ready`. Start a separate dashboard-mode process only when an operator UI is required. | +| HTTP returns `401 Unauthorized` | Missing `Authorization: Bearer `, wrong token, duplicate authorization headers, or a client still using the old token after rotation. | Reinstall or update the client secret, reconnect, and avoid printing headers in diagnostics. | +| HTTP returns `400` on a new MCP session | The first request was not JSON-RPC `initialize`, or `mcp-session-id` / `x-code-ux-agent` was malformed. | Let the MCP SDK initialize the session, or clear stale session state and reconnect. | +| Session cap errors appear | Too many active Streamable HTTP sessions, usually from leaked clients or a cluster larger than the default cap. | Stop stale clients, shorten `MCP_HTTP_SESSION_TIMEOUT_MS`, or raise `MCP_HTTP_MAX_SESSIONS` within server capacity. | +| Worker appears stale or offline | Heartbeats stopped, the worker process is down, network access failed, or the stable connection key changed unexpectedly. | Restart the worker with the same `--connection-key`, verify `/ready`, and check logs for bounded connection metadata. | +| Worker connects but does not claim work | No active project assignment, project not included in `--project-id` / `--active-project-id`, stale endpoint status, task executor mismatch, or no lease returned. | Confirm project assignment and worker status, then verify queued dispatches. Do not start local execution without a lease token. | +| `/health` passes but `/ready` fails | Listener is alive but runtime readiness has not completed or the server is degraded. | Wait for startup recovery to finish, then inspect structured logs. Use `/ready` for load balancer readiness gates. | +| `/ready` reports credential custody unavailable | The explicit mounted-file, Vault, or KMS provider is missing, insecure, unhealthy, or cannot return the required key version. | Keep runners disabled, inspect metadata-only readiness and provider configuration, and restore the exact provider/key version. Do not print key material or substitute a new key for encrypted rows. | +| A credential operation returns a version conflict | Another operator changed metadata, scope, capabilities, status, or encrypted value first. | Refresh the metadata-only record, review the new version, and intentionally retry with that version. Do not bypass optimistic concurrency. | +| Secret values appear in an exported settings bundle | The export was explicitly approved with `includeSecrets: true`. | Store the bundle only in approved secret storage, rotate exposed credentials if it was shared, and prefer redacted exports for review. | + +## Related Docs + +- [MCP Tools](/docs/developer-mcp-tools) +- [Security Hardening](/docs/operations-security-hardening) +- [Automation Credential Security](/docs/operations-credential-security) +- [Runtime Configuration](/docs/developer-configuration) diff --git a/docs-web/content/docs/registry.ts b/docs-web/content/docs/registry.ts index c346b5dcaf..c64bd719a6 100644 --- a/docs-web/content/docs/registry.ts +++ b/docs-web/content/docs/registry.ts @@ -273,7 +273,7 @@ export const docsRegistry: Record = { id: 'user-dashboard-node-flows', path: '/docs/user-dashboard-node-flows', section: 'User Guide', - title: "Node Flows", + title: "Node Flows Dashboard", description: "The Nodes page (/nodes) is the project-scoped backend authoring, publication, and operations surface for canonical node flows. No selected project means no flow library, credential metadata, publications, or durable r...", }, 'user-dashboard-scheduler': { @@ -561,7 +561,7 @@ export const docsRegistry: Record = { path: '/docs/settings-integrations', section: 'User Guide', title: "Integrations", - description: "Lists provider, git-host, issue-tracker, and read-only importer integrations and exposes manage/add actions.", + description: "Lists automation credentials, providers, git hosts, issue trackers, and read-only importer integrations and exposes manage/add actions.", }, 'settings-jules-automation': { id: 'settings-jules-automation', @@ -834,7 +834,7 @@ export const docsRegistry: Record = { path: '/docs/operations-credential-security', section: 'User Guide', title: "Automation Credential Security", - description: "Code UX resolves canonical node credential IDs and named project binding keys through the credential broker. Stored values are not exposed to nodes, dashboard reads, MCP payloads, agent context, run inspection records...", + description: "Code UX stores automation credentials through a broker rather than exposing secret values to node definitions, dashboard reads, MCP payloads, agent context, or run inspection records. Canonical node bindings reference...", }, 'operations-runbook': { id: 'operations-runbook', @@ -854,8 +854,8 @@ export const docsRegistry: Record = { id: 'operations-server-mode', path: '/docs/operations-server-mode', section: 'User Guide', - title: "Authenticated Headless Server Mode", - description: "Code UX separates MCP bearer access from the authenticated dashboard administrative API. Remote dashboard/API deployments must use digest-backed service identities or terminate OIDC at a trusted reverse proxy; loopbac...", + title: "Secure Headless Server Mode", + description: "Server mode runs Code UX as an authenticated MCP HTTP control plane without binding the dashboard UI, dashboard REST routes, dashboard realtime websocket, terminal websocket, or static dashboard assets. Use it for hea...", }, 'settings-google-drive-mount': { id: 'settings-google-drive-mount', @@ -897,7 +897,7 @@ export const docsRegistry: Record = { path: '/docs/architecture-node-flow-builtins-and-security', section: 'Architecture', title: "Node Flow Built-ins and External-Effect Security", - description: "The governed catalog adds deterministic branches, bounded collection processing, durable approvals, and replay-safe external effects while keeping the versioned definition registry as the executable authority.", + description: "The governed built-in catalog extends publication-based node-flow execution with deterministic control nodes and durable boundaries for external effects. The definition registry remains the executable authority; a gra...", }, 'architecture-node-flow-durable-execution': { id: 'architecture-node-flow-durable-execution', @@ -918,7 +918,7 @@ export const docsRegistry: Record = { path: '/docs/architecture-node-flows', section: 'Architecture', title: "Node Flows", - description: "Node flows are project-owned, versioned Graph v2 workflows.", + description: "Node flows are project-scoped, repeatable workflow graphs for turning an operator or agent-defined procedure into a saved Code UX workflow. They are not a generic n8n compatibility layer. A good flow uses Code UX conc...", }, 'architecture-speech-input': { id: 'architecture-speech-input', diff --git a/docs-web/content/docs/settings-integrations.mdx b/docs-web/content/docs/settings-integrations.mdx index 67e686660b..47ab855fa7 100644 --- a/docs-web/content/docs/settings-integrations.mdx +++ b/docs-web/content/docs/settings-integrations.mdx @@ -1,19 +1,19 @@ # Integrations -Lists provider, git-host, issue-tracker, and read-only importer integrations and exposes manage/add actions. +Lists automation credentials, providers, git hosts, issue trackers, and read-only importer integrations and exposes manage/add actions. > Settings area: `integrations` > Dashboard documentation route: `/docs/settings-integrations` ## What This Area Is For -Lists provider, git-host, issue-tracker, and read-only importer integrations and exposes manage/add actions. This page expands the short Settings-page help text into an operator reference for deciding when to change this area, what behavior the controls affect, and what to verify after saving. +Lists automation credentials, providers, git hosts, issue trackers, and read-only importer integrations and exposes manage/add actions. This page expands the short Settings-page help text into an operator reference for deciding when to change this area, what behavior the controls affect, and what to verify after saving. Use it when you are configuring a new project, auditing inherited settings, or debugging behavior that changed after a system, project, or sprint override was saved. ## Controls And Runtime Effect -Cards show connection state, auth hints, active/configured importer status, and management entry points; host hints can import detected local settings. +Cards show connection state, auth hints, active/configured importer status, and management entry points; host hints can import detected local settings. Automation Credentials is the first catalog entry and reports secure-storage unavailable, ready but unconfigured, or configured state for the selected project. Its **Manage** action uses the same detail and back-navigation behavior as every other integration. | Control Surface | Runtime Effect | Review Before Saving | | --- | --- | --- | @@ -21,9 +21,30 @@ Cards show connection state, auth hints, active/configured importer status, and | Inherited values | Values can flow from system defaults into project and sprint behavior. | Check the source badge before assuming a value is project-specific. | | Related runtime paths | The affected service reads the saved settings during planning, dispatch, dashboard rendering, or maintenance work. | Re-run the affected workflow after changing operational settings. | +## Automation Credential Management + +Credential management is project-aware even when Settings is displaying system scope. Select a project before opening the detail view so Code UX can list only metadata visible to that project and determine whether the project has management authority. + +The create form requires an explicit name, kind, project or global scope, capability selection, and—when global scope is selected—an allowlist that retains the managing project. No capability is granted implicitly. Global creation and project-to-global promotion require confirmation because they expand access. + +Each project-managed credential supports bounded rename, metadata-only validation test, value rotation, encrypted-state replacement, monotonic access restriction, confirmed promotion, and confirmed revocation. Revocation requires typing `REVOKE` exactly; each lifecycle confirmation starts with cleared confirmation state and returns focus to the credential controls when it closes. Every lifecycle request uses the metadata version shown by the service. If another session wins the compare-and-swap update, the detail view refreshes metadata and asks the operator to review and retry instead of overwriting the newer state. + +| Workflow | What the operator supplies | What remains readable afterward | +| --- | --- | --- | +| Create | Name, kind, write-only value, explicit capabilities, and project/global policy | Metadata, configured state, validation state, scope, capabilities, and version only. | +| Update metadata | A bounded display name and current version | Updated metadata; kind and management ownership cannot be changed. | +| Rotate / replace | A new write-only value and current version | New key/version and validation metadata, never either the old or new value. | +| Test | The current version | `valid`, `invalid`, or `unavailable` plus timestamps; no tested value or low-level custody error. | +| Restrict / promote | A monotonic restriction, or a confirmed global allowlist expansion owned by the managing project | Updated non-secret policy metadata. | +| Revoke | Exact confirmation and current version | Revoked status and audit metadata; the stored value cannot be read back. | + +Secret inputs are write-only. Create, rotate, and replace fields are never populated from responses, are cleared after successful or failed submissions and project changes, and are removed with the detail view. Notices, metadata cards, browser storage, and reusable drafts contain no secret value. An allowlisted project that is not the management owner sees a **Use only** state and cannot invoke management actions. + +Unavailable key custody leaves non-secret metadata visible and disables secret-bearing changes and tests. Follow the inline custody guidance, restore secure storage, then use **Refresh**. See [Automation Credential Security](/docs/operations-credential-security) for encryption, authority, recovery, and API behavior. + ## Recommended Configuration -Configure provider and importer credentials at system scope and use project overrides only for repository-specific git hosts or importer defaults. +Configure provider and importer credentials at system scope and use project overrides only for repository-specific git hosts or importer defaults. Automation credentials follow their own project-aware ownership and allowlist policy rather than Settings inheritance. For Google Drive, link an existing host-side sync or mount directory and enable the opt-in Docker mount only for projects that need it. The mount defaults to read-only; see [Google Drive Project Mount](/docs/settings-google-drive-mount) for access, inheritance, security, and troubleshooting details. This integration does not configure Google Drive API synchronization or credentials. @@ -51,11 +72,14 @@ If the saved setting does not appear to take effect: - Check for a project or sprint override that takes precedence over the system value. - Refresh the affected dashboard page if the setting controls a rendered surface. - Restart the local runtime only when the setting explicitly controls startup, listener, or process-level behavior. +- If secure custody is unavailable, keep the metadata view open, restore the deployment's supported custody provider, and use **Refresh**. Local loopback CLI/dashboard mode provisions its owner-only user-home key automatically; do not add mounted-key configuration for a normal local user. +- If a save reports stale metadata, review the refreshed record before retrying with its new version. Never copy secret fields into notes, browser storage, logs, or a repository as a workaround. ## Related Documentation - [Settings overview](/docs/settings-overview) +- [Automation Credential Security](/docs/operations-credential-security) - [Google Drive Project Mount](/docs/settings-google-drive-mount) - [Dashboard Settings](/docs/user-dashboard-settings) -- [Configuration and Storage](/docs/developer-settings-reference) -- [Security Hardening](/docs/user-troubleshooting) +- [Runtime Configuration](/docs/developer-configuration) +- [Security Hardening](/docs/operations-security-hardening) diff --git a/docs-web/content/docs/user-dashboard-custom-dashboards.mdx b/docs-web/content/docs/user-dashboard-custom-dashboards.mdx index 9bc9006971..5ba4672c86 100644 --- a/docs-web/content/docs/user-dashboard-custom-dashboards.mdx +++ b/docs-web/content/docs/user-dashboard-custom-dashboards.mdx @@ -2,59 +2,140 @@ Custom dashboards are project-scoped dashboard apps generated and revised by agents, then validated in a detached Docker runtime before publication. Use them when the built-in dashboard pages do not match the operational view a team needs, such as a project-specific release panel, sprint-health cockpit, or integration-status board. -## Workflow +The source of truth is the Code UX database. Drafts stay mutable, revisions are immutable snapshots, validation sessions record build/runtime results, and publication is a single active pointer to one validated revision. -1. Ask the Project Manager for the dashboard you want. Include the purpose, target audience, data sources, layout preferences, review criteria, and whether it should be published after validation. -2. Review the draft at `/custom-dashboards`. Drafts expose manifest JSON, generated file bundle content, source-node graph JSON, styleguide JSON, and data catalog selections. +## User Workflow + +1. Ask the Project Manager for the dashboard you want. Include the purpose, target audience, data sources, layout preferences, review criteria, and whether the dashboard should be published after validation. +2. Review the draft in the dashboard workspace at `/custom-dashboards`. The draft includes editable manifest JSON, generated file bundle content, source-node graph JSON, styleguide JSON, and data catalog selections. 3. Ask for changes or edit the draft before creating a revision. Draft edits do not change previous revisions or the currently published dashboard. -4. If the manifest declares credential slots, open the editor's **Credentials** tab. It shows bounded declarations and current non-secret metadata, and offers only active, configured, project-authorized credentials that satisfy the declared kinds and capabilities. Bind every required slot; no secret value is entered into the dashboard draft or generated code. -5. Create a revision when the draft is ready. A revision snapshots the current manifest, files, source graph, styleguide, runtime metadata, and credential-ID bindings. -6. Run detached validation. Code UX reviews bindings before it builds the revision in Docker, captures the browser-ready Vite artifact, starts a detached preview container, and health-checks the root URL. -7. Inspect validation status, logs, and the proxied preview link. Validation passes only after credential policy, install, build, artifact capture, container start, and root health checks succeed. -8. Publish the validated revision. Publication rechecks credential metadata and remains blocked unless the revision has a passed validation report. -9. Roll back by publishing an earlier passed revision, or archive the dashboard to clear its active publication while preserving history. +4. If the manifest declares credential slots, open the editor's **Credentials** tab. It shows each bounded declaration and current non-secret credential metadata, and offers only active, configured, project-authorized credentials that satisfy the allowed kinds and required capabilities. Bind every required slot; no secret value is entered into the dashboard draft or generated code. +5. Create a revision when the draft is ready. A revision snapshots the current manifest, file bundle, source graph, styleguide, runtime metadata, and credential-ID bindings. +6. Run detached validation for the revision. Code UX reviews bindings before it materializes the bundle, builds it in Docker, starts a detached preview container, and health-checks the root URL. +7. Inspect validation status, logs, and the proxied preview link. Validation passes only after credential policy, install, build, browser artifact capture, container start, and root health checks succeed. A passed validation does not publish by itself. +8. Publish the validated revision. Publication rechecks credential metadata and requires `validationStatus: "passed"` with a valid validation report. Publishing another passed revision is the rollback path. +9. Archive dashboards you no longer want active. Archiving clears the active publication and marks the dashboard archived while preserving revision and validation history. + +If validation fails, use the report and logs to create a new revision. Do not publish around the failure; the repository rejects failed, queued, running, cancelled, missing, or mismatched validation sessions before publication state changes. When a dashboard is already published, validating later drafts keeps the active published dashboard open, and validation sessions for the active published revision do not replace its published validation snapshot. + +The Credentials tab appears only when the saved manifest declares slots. Secure-backend failures and empty compatible lists link to credential management in Settings. Binding, replacement, and unbinding use the current optimistic binding revision; a concurrent edit refreshes the dashboard and requires an explicit retry instead of overwriting the other operator. Required unbinding immediately shows the draft as not ready for its next revision, while optional unbound slots remain valid. Every successful binding change refreshes validation and publication readiness. + +Credential selection and actions are keyboard accessible, restore focus after completion, and announce saving or error state. Credential IDs remain confined to the dedicated metadata-management request state and never enter manifest, generated-file, source-graph, styleguide, runtime-text, or secret-value fields. + +If secure custody is unavailable, the Credentials tab keeps existing bindings unchanged, reports metadata-only readiness, and links to Settings. Restore the supported custody provider and refresh the review; do not put a key or credential value in manifest JSON, generated files, validation logs, or project files. If a bind/unbind returns a stale binding revision, the editor refreshes declarations, candidates, bindings, and readiness, then requires an explicit retry. If validation or publication denies a formerly compatible binding, refresh review because revocation, restriction, project access, capabilities, kind policy, or custody health may have changed. + +## Agent Workflow -If validation fails, use the report and logs to create a new revision. Code UX rejects failed, queued, running, cancelled, missing, or mismatched validation sessions before publication state changes. When a dashboard is already published, validating later drafts keeps the active published dashboard open, and validation sessions for the active published revision do not replace its published validation snapshot. +Project Manager agents should use the `manage_custom_dashboards` MCP surface rather than writing generated code into `dashboard/src`. -The Credentials tab appears only for manifests with declared slots. Unavailable secure custody and empty compatible lists link to credential management in Settings. Binding changes use the current optimistic revision; a conflict refreshes the dashboard and asks for an explicit retry. Required unbinding marks the next revision as not ready, while optional unbound slots remain valid. Successful changes refresh validation and publication readiness. The controls support keyboard selection, visible focus, focus restoration, and live save/error announcements. Credential IDs stay out of manifest, file, source, styleguide, and runtime text editors, and the editor never requests or displays secret values. +Recommended sequence: -## Data Sources +1. Gather missing requirements for purpose, audience, source data, style, accessibility, and publication intent. +2. Call `data_catalog` for the project when reusing existing custom-dashboard source declarations. +3. Call `create` or `update` with a complete manifest, file bundle, source-node graph, styleguide, and runtime metadata. +4. Call `list_credential_slots` when the manifest declares slots. Select only candidate credential IDs reported compatible, then call `bind_credential` with the current `expectedBindingRevision` and complete the human-approval flow. Use `unbind_credential` before changing a bound slot's policy. +5. Call `create_revision` to snapshot the draft and binding IDs. +6. Call `validate_revision`, then poll `validation_status` and read `validation_logs` when the session is not passed. +7. Repair failures by updating the draft or binding metadata and creating a new revision. +8. Call `publish_revision` only after validation passed. Include `validationSessionId` when publishing from the session just reviewed. +9. Use `archive` only after human approval; the action follows the standard destructive-action approval flow. -Custom dashboards declare a `sourceNodeGraph` with nodes, edges, and optional metadata. Nodes have `id`, `type`, `title`, and optional JSON `config`. +## Data-Source Node Graph + +Each dashboard draft and revision can declare a `sourceNodeGraph`: + +```json +{ + "nodes": [ + { "id": "execution", "type": "project_dashboard_data", "title": "Project execution" }, + { "id": "stats", "type": "stats", "title": "Seven-day stats", "config": { "window": "7d" } } + ], + "edges": [], + "metadata": {} +} +``` + +Nodes have `id`, `type`, `title`, and optional JSON `config`. Edges have `fromNodeId`, `toNodeId`, and an optional `id`. The graph records the data the generated dashboard expects; it is also used by the in-app viewer to decide which source requests are allowed. + +Supported user-level source types: | Source type | Runtime behavior | | --- | --- | -| `project_dashboard_data`, `project_dashboard`, `dashboard_data` | Reads project execution data. | -| `stats`, `project_stats` | Reads project stats. `config.window` selects the stats window when present. | -| `telemetry`, `overview_telemetry` | Reads overview telemetry. | -| `integrations_metadata`, `integrations` | Returns only non-secret metadata declared on the source node. | -| `external_api` | Placeholder only. Arbitrary external calls are not proxied and return an unavailable-source error. | +| `project_dashboard_data`, `project_dashboard`, `dashboard_data` | Reads project execution data from `GET /api/projects/:projectId/execution`. | +| `stats`, `project_stats` | Reads project stats from `GET /api/projects/:projectId/stats`; `config.window` selects the stats window when present, otherwise `7d` is used. | +| `telemetry`, `overview_telemetry` | Reads overview telemetry from `GET /api/telemetry/overview`. | +| `integrations_metadata`, `integrations` | Returns only the non-secret metadata declared on the source node. It does not expose provider credentials or effective settings secrets. | +| `external_api` | Placeholder only in the in-app viewer. It is declared in the graph and validation bridge, but arbitrary external calls are not proxied and return an unavailable-source error. | + +Unsupported source types return an explicit unavailable-source error. Generated dashboards should handle these errors visibly instead of assuming all declared data is available. + +## REST API Surface + +Custom dashboard routes are registered with the dashboard server: + +| Method | Route | Purpose | +| --- | --- | --- | +| `GET` | `/api/projects/:projectId/custom-dashboards` | List dashboards for a project. | +| `POST` | `/api/projects/:projectId/custom-dashboards` | Create a mutable draft. | +| `GET` | `/api/projects/:projectId/custom-dashboards/data-catalog` | Return project dashboard summaries and declared source nodes. | +| `GET` | `/api/custom-dashboards/:dashboardId` | Return a dashboard plus revisions. | +| `PATCH` | `/api/custom-dashboards/:dashboardId` | Update mutable draft fields. | +| `DELETE` | `/api/custom-dashboards/:dashboardId` | Archive the dashboard and clear active publication. | +| `POST` | `/api/custom-dashboards/:dashboardId/revisions` | Create an immutable revision from the draft or supplied overrides. | +| `POST` | `/api/custom-dashboards/:dashboardId/revisions/:revisionId/validate` | Start a detached validation session. Body may include `projectId`; otherwise the server resolves it from the revision. | +| `POST` | `/api/custom-dashboards/:dashboardId/revisions/:revisionId/publish` | Publish a validated revision, optionally with `validationSessionId`. | +| `GET` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings?revisionId=...` | Review draft or revision slots, current bindings, backend health, and bounded compatible credential metadata. | +| `PUT` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` | Bind or replace one slot using `slotId`, `credentialId`, and `expectedBindingRevision`. | +| `DELETE` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings/:slotId` | Unbind one slot using `expectedBindingRevision`. | +| `GET` | `/api/custom-dashboard-validations/:sessionId` | Read validation session status and runtime metadata. | +| `GET` | `/api/custom-dashboard-validations/:sessionId/logs?tail=200` | Read bounded validation and container logs. | +| `POST` | `/api/custom-dashboard-validations/:sessionId/stop` | Stop the detached validation container. | +| `DELETE` | `/api/custom-dashboard-validations/:sessionId` | Remove a validation session after cleanup. | +| `ALL` | `/api/custom-dashboard-validations/:sessionId/proxy{*rest}` | Same-origin proxy to the detached validation runtime. | +| `ALL` | `/api/custom-dashboards/validation-sessions/:sessionId/proxy{*rest}` | Backward-compatible validation proxy route. | + +The binding routes are credential-management routes: authenticated remote callers require `credential_admin`, project access, and enabled remote credential management. Stale binding revisions return `409`; incompatible credential selection returns `403`. Publication first repeats metadata-only binding review, then applies the repository validation gate. REST and MCP denials preserve a sanitized `issues` array with slot-specific `field`, `code`, and `message` values while omitting credential IDs and values. Active publications remain the opening source of truth while later validation sessions run. + +## MCP Surface + +The dedicated MCP tool is `manage_custom_dashboards` and is available to the project-manager runtime role. It supports: + +- `list`, `get`, `create`, `update` +- `create_revision` +- `validate_revision`, `validation_status`, `validation_logs` +- `publish_revision` +- `archive` +- `data_catalog` +- `list_credential_slots`, `bind_credential`, `unbind_credential` + +Credential actions use `projectId`, `dashboardId`, `slotId`, `credentialId`, and `expectedBindingRevision`; an optional `revisionId` reviews an immutable snapshot. Bind and unbind require the normal stateful human-approval handshake. Before creating any approval fingerprint, Code UX rejects secret, header, environment, malformed approval, and other undeclared fields, then rebuilds the approval payload from only the allowed metadata. Other important fields include `sessionId`, `validationSessionId`, `title`, `description`, `manifest`, `fileBundle`, `sourceNodeGraph`, `styleguide`, `runtimeMetadata`, `tail`, and `approval`. + +The dashboard chat JSON-action bridge also understands the legacy `custom_dashboards` management domain, but agents should prefer the dedicated MCP tool when it is available. + +## Validation Runtime + +Validation sessions move through `queued`, `building`, `running`, `passed`, `failed`, or `cancelled`. -Generated dashboards should handle unavailable-source errors visibly. External API connectors are not fully available through the in-app viewer yet. +During validation, Code UX: -## Agent and API Notes +- performs metadata-only compatibility review for every bound slot and every required slot +- creates a validation session row and runtime directory under the selected project +- writes the generated bundle plus a known Vite/Preact harness +- injects a read-only `codeUxDataBridge` / `CodeUXCustomDashboard` object +- runs install and build in Docker using the resolved CLI workflow image +- persists the built Vite `dist` files on the validated revision as the published-viewer artifact +- starts a detached preview container on an allocated localhost port +- health-checks the root URL before marking the session passed +- records workspace path, log path, container id/name, host port, validation proxy path, commands, and log excerpts in runtime metadata -Project Manager agents use the `manage_custom_dashboards` MCP tool to create drafts, list credential slots, bind or unbind credential IDs, create revisions, validate revisions, inspect logs, publish passed revisions, archive dashboards, and read the data catalog. Credential mutations require the normal stateful human-approval handshake and an optimistic `expectedBindingRevision`; unsupported or secret-bearing fields are rejected before approval state is created. +Required missing bindings and bound credentials that are missing, revoked, inaccessible, unconfigured, wrong-kind, missing capabilities, or blocked by unavailable/insecure key custody fail with slot-specific issues before workspace creation. Optional unbound slots remain valid. No custom-dashboard path resolves secret plaintext: credential values and binding IDs stay out of generated source, file bundles, bridge files, Docker arguments and mounts, validation reports and logs, viewer records, iframe configuration, and browser messages. Generic response and viewer boundaries recursively redact known binding IDs from nested manifests, file content and metadata, source graphs, styleguides, runtime metadata, validation reports, and persisted viewer artifacts. Dedicated credential-binding management responses may return credential IDs and non-secret metadata so operators and agents can select them. -The same workflow is available through the dashboard REST API: +The `build` and `runtime` slot phases are bounded declarations used for review and policy only. They do not inject a secret into the build container, published artifact, iframe, MCP result, or runtime data bridge. This feature does not migrate or expose broader provider secrets. -- `GET/POST /api/projects/:projectId/custom-dashboards` -- `GET /api/projects/:projectId/custom-dashboards/data-catalog` -- `GET/PATCH/DELETE /api/custom-dashboards/:dashboardId` -- `POST /api/custom-dashboards/:dashboardId/revisions` -- `POST /api/custom-dashboards/:dashboardId/revisions/:revisionId/validate` -- `POST /api/custom-dashboards/:dashboardId/revisions/:revisionId/publish` -- `GET /api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` -- `PUT /api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` -- `DELETE /api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings/:slotId` -- `GET /api/custom-dashboard-validations/:sessionId` -- `GET /api/custom-dashboard-validations/:sessionId/logs` -- `POST /api/custom-dashboard-validations/:sessionId/stop` -- `DELETE /api/custom-dashboard-validations/:sessionId` -- `ALL /api/custom-dashboard-validations/:sessionId/proxy{*rest}` +Stopping a validation session removes the detached container. It does not invalidate a passed revision report. Removing a validation session deletes the session row after cleanup; the revision's validation metadata remains the publication gate. -Remote callers to credential-binding routes require the credential-administrator role, project access, and enabled remote credential management. Required missing bindings and bound credentials that are revoked, inaccessible, unconfigured, wrong-kind, missing capabilities, or blocked by unavailable key custody fail before workspace creation and are rechecked before publication. REST and MCP publication denials include sanitized slot-specific issues without credential IDs or values. Optional unbound slots remain valid. +## Published Viewer and Rollback -Custom-dashboard binding is metadata-only: no secret is resolved, and credential values and binding IDs are excluded from generated files, bridges, Docker configuration, validation output, generic REST/MCP responses, iframe configuration, and browser messages. Generic response and viewer boundaries recursively redact known IDs from nested manifests, files, source graphs, runtime metadata, validation reports, and viewer artifacts. Dedicated binding-management responses may return credential IDs and non-secret metadata so operators and agents can select them. +The in-app viewer renders only published dashboards whose active `publishedRevisionId` points to a revision with a passed validation report. For the default `src/dashboard.tsx` draft and other TSX/Preact revisions validated through the harness, the viewer uses the persisted Vite `dist` artifact instead of the source entry file, so publication does not depend on the detached validation container still running. Generated code runs inside a sandboxed iframe document and talks to the parent app through a constrained `postMessage` bridge. The parent serves only declared source-node requests. -Published dashboards render inside a sandboxed iframe. For TSX/Preact drafts such as the default `src/dashboard.tsx` bundle, the viewer uses the persisted validation artifact instead of the source entry file, so it can open after publication even when the detached validation preview is gone. The frame can request only declared source nodes through the Code UX bridge, parent and frame handlers verify the expected window source, and the parent dashboard returns data through same-origin API calls. +Rollback is publish-based: select an earlier passed revision and publish it again. The publication pointer moves back to that immutable revision. Archive is the safe removal path when no dashboard should be active; it clears the publication pointer while preserving history. diff --git a/docs-web/content/docs/user-dashboard-node-flows.mdx b/docs-web/content/docs/user-dashboard-node-flows.mdx index c767364e31..f77885f78b 100644 --- a/docs-web/content/docs/user-dashboard-node-flows.mdx +++ b/docs-web/content/docs/user-dashboard-node-flows.mdx @@ -1,42 +1,44 @@ -# Node Flows +# Node Flows Dashboard The **Nodes** page (`/nodes`) is the project-scoped backend authoring, publication, and operations surface for canonical node flows. No selected project means no flow library, credential metadata, publications, or durable run history are requested. -## Library, Drafts, And Migration +## Library, drafts, and migration -The flow library contains backend drafts and publications owned by the active project. Saves include the loaded draft revision, so a concurrent edit produces a visible conflict and never overwrites newer work. +The library loads through `GET /api/projects/:projectId/node-flows`. Drafts are created through `POST /api/projects/:projectId/node-flow-drafts` and saved through revision-checked `PATCH /api/node-flow-drafts/:flowId`. A stale revision produces a visible conflict and never overwrites newer work. -The former browser graph at `codeux:nodes-canvas:v1` is eligible for one import into the selected project. The bridge maps `trigger`, `agent`, and `task` to registered `input`, `set_fields`, and `provider_prompt` definitions, retains `condition` and `output`, and remaps their ports before creating an **Imported Nodes Canvas** draft. A failed import remains retryable and does not block the normal library load; only success removes the old value and records the marker. +The former browser graph at `codeux:nodes-canvas:v1` is eligible for one import into the selected project. The bridge maps `trigger` to `input`, `agent` to `set_fields`, and `task` to `provider_prompt`; `condition` and `output` remain governed definitions, ports are remapped, and legacy configuration is retained as non-secret metadata. Code UX creates an **Imported Nodes Canvas** backend draft and only then removes the legacy value and records a project-specific marker. A failed import remains retryable and is isolated from normal library loading, while a successful marker prevents duplicates. -## Registry-Driven Editing And Credentials +## Registry-driven editing and credentials -The registry list returns flat versioned palette summaries. Selecting a definition loads the full manifest from the node-type detail endpoint, including nested `ui.widgetSchema`, configuration schema, policies, documentation, and deprecation metadata. The inspector renders from that full contract. Graphs reference a definition version and store non-secret configuration and credential ids; they do not contain custom-node source or resolved credentials. +`GET /api/node-flow-catalog` returns flat versioned palette summaries. `GET /api/node-flow-catalog/:nodeType` returns the full `NodeDefinitionManifest`, including nested `ui.widgetSchema`, configuration schema, policies, documentation, and deprecation metadata. The inspector renders from that full contract. Graphs reference a definition version and store non-secret configuration and credential ids; they do not contain custom-node source or resolved credentials. Credential slots use the versioned definition's allowed kinds and required capabilities to offer project-visible credential metadata. Only active, configured credentials with project access and a healthy secure backend are selectable; unavailable entries explain the operator-facing reason without exposing secret or key-custody details, and an empty compatible set links directly to **Settings → Integrations**. -Selecting, replacing, or removing a credential updates only that slot in the node's canonical `credentialBindings` and immediately saves the complete draft through the current optimistic revision. The dashboard then adopts the canonical flow revision and refreshes governed review. Saving, saved, policy-denial, and error states are announced. A revision conflict loads the latest draft, preserves the selected slot workflow and sibling edits, and requires the operator to choose again rather than replaying the stale mutation. Credential plaintext remains behind the broker and is excluded from graph data, component state, and browser output. +Selecting, replacing, or removing a credential updates only that slot in the node's canonical `credentialBindings` and immediately saves the complete draft through the current optimistic revision. The dashboard then adopts the canonical flow revision and refreshes governed review. Saving, saved, policy-denial, and error states are announced. A revision conflict loads the latest draft, preserves the selected slot workflow and sibling edits, and requires the operator to choose again rather than replaying the stale mutation. Credential plaintext remains behind the broker and is excluded from graph data, component state, browser output, logs, and documentation examples. + +Removing a required binding is allowed as a draft edit but immediately changes review and publication readiness to blocked; removing an optional binding remains valid. Publication is denied for required missing bindings and for credentials that become unavailable, unconfigured, revoked, inaccessible to the project, wrong-kind, or short of a required capability. Runtime repeats compatibility against the immutable publication, so a later custody outage, restriction, revocation, or rebinding denies execution instead of using a stale dashboard decision. The complete governed built-in set currently registered with executable handlers is `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, `condition`, `switch`, `foreach`, `merge`, `delay`, `approval`, `email_draft`, `email_send`, `execute_subflow`, `webhook_trigger`, and `output`. -Registered custom definitions can execute only when their validated versioned manifest, immutable artifact, and custom-node runtime are available. Raw legacy kinds are translated during import rather than executed directly. Unknown or unregistered types, mockup entries, and definitions marked non-executable remain planned or unavailable. +Registered custom definitions can execute only when their validated versioned manifest, immutable artifact, and custom-node runtime are available. Raw legacy `trigger`/`agent`/`task` kinds are translated by the browser import bridge rather than executed directly. Unknown or unregistered types, mockup entries, and definitions marked non-executable are planned or unavailable definitions. -## Governance And Publication +## Governance and publication Draft review provides structural validation, policy findings, requested permissions, side-effect review, and a non-executing dry run. Publication requires the current draft revision, a valid governed review, and all required credentials. Each publication is an immutable snapshot; comparison and rollback operate on versioned history, and only a pinned or latest-published version can execute. -## Durable Debugger And Scheduling +## Durable debugger and scheduling + +The debugger reads persisted flow runs, node runs, attempt history, retry classifications and decisions, approval records, invocation links, timing, and redacted input and output. Pending approvals expose keyboard-accessible **Approve & continue** and **Reject** actions. A decision continues or terminates the same pinned run, and repeated decisions return its current durable state without duplicating a governed attempt or external send. The debugger also supports cancellation and safe retry. -The debugger reads persisted flow runs, node runs, attempt history, retry classifications and decisions, approval records, invocation links, timing, and redacted input and output. Pending approvals offer **Approve & continue** and **Reject** actions. A decision continues or terminates the same pinned run, and repeated decisions return its current durable state without duplicating a governed attempt or external send. The debugger also supports cancellation and safe retry. +Foreach runs persist one downstream node run and attempt sequence per deterministic logical item. Item inputs, retries, cancellation, approvals, and side-effect identity survive restart; concurrency is bounded by the node configuration. Empty collections select the explicit `empty` branch and persist the item branch as skipped, while oversized collections fail instead of being truncated. -Use the [Scheduler](/docs/user-dashboard-scheduler) to target a pinned or latest-published version. A flow can also be attached to a project agent preset as a reusable skill; removing the attachment does not remove the flow, publications, schedules, or run history. +The layout stacks on small screens, preserves keyboard-visible focus, labels loading/error/empty states, and bounds long histories and JSON output with scrolling. Rendered run payloads redact secret-shaped keys such as `apiKey`, `authorization`, `cookie`, `password`, `secret`, and `token`. The run debugger lists durable approvals beside node attempts. A pending item offers **Approve & continue** and **Reject** actions. The decision applies to the same pinned run, and repeated clicks return its current state without sending an approved external effect twice. -Foreach executes the selected downstream branch once per deterministic logical item. The node's `concurrency` setting bounds active items, `maxItems` rejects oversized inputs, and zero items explicitly select the `empty` branch. Item-specific inputs, retries, cancellation, approvals, and external-effect identity are persisted so restart continuation does not replay completed items or duplicate sends. - -## Agent Attachment +## Agent attachment A selected project loads its agent presets, and selecting a flow loads that flow's current bindings. The inspector exposes only agent names and attachment skill metadata; it never renders agent instructions, custom source, credential values, or decrypted material. @@ -44,9 +46,9 @@ Attaching and detaching use the governed node-flow attachment routes, then refre A flow can be attached to a project agent preset as a repeatable skill with a name and description. Detaching removes only that binding; the flow, its graph, schedules, and run history remain in the project. -## Scheduling +Scheduling is entered through `/scheduler` and targets a pinned or latest-published version. A flow can also be attached to a project agent preset as a reusable skill; removing the attachment does not remove the flow, publications, schedules, or run history. -Use the [Scheduler](/docs/user-dashboard-scheduler) page to run a saved node flow once or on a recurrence. Scheduled node-flow entries select a project-owned flow and may include optional JSON object input. Pause, resume, failure handling, and due-run behavior match the normal scheduler model. +Scheduled node-flow entries select a project-owned flow and may include optional JSON object input. Pause, resume, failure handling, and due-run behavior match the normal scheduler model. ## Graph v2 boundary diff --git a/docs-web/operations/credential-security.md b/docs-web/operations/credential-security.md index 41fa50e335..48665eb98f 100644 --- a/docs-web/operations/credential-security.md +++ b/docs-web/operations/credential-security.md @@ -1,50 +1,76 @@ # Automation Credential Security -Code UX resolves canonical node credential IDs and named project binding keys through the credential broker. Stored values are not exposed to nodes, dashboard reads, MCP payloads, agent context, run inspection records, or access audits. +Code UX stores automation credentials through a broker rather than exposing secret values to node definitions, dashboard reads, MCP payloads, agent context, or run inspection records. Canonical node bindings reference credential metadata by ID; only the broker can resolve the value at execution time after project and capability checks. Named project binding keys use the same broker for other automation consumers. ## Scope and policy -- Project credentials are owned by one project. -- Global credentials require an explicit project allowlist and retain the configuring project as their management owner. Other allowlisted projects may bind and resolve the credential but cannot mutate it. -- The credential kind must be allowed, and both the binding and credential must approve every declared capability before one secret read. +- Project credentials can be managed only through their owning project. +- Global credentials are opt-in and require an explicit project allowlist containing the configuring project. The configuring project remains the credential's management owner after promotion; other allowlisted projects may bind and resolve it but cannot rotate, replace, revoke, promote, or restrict it. +- Resolution succeeds only when the credential kind is allowed and both the credential and binding approve every declared capability. Authorization is completed before the broker performs its single secret read. - Revoked, unavailable, missing, cross-project, or insufficiently capable credentials fail closed. Node-flow definition slots explicitly declare required/optional state, allowed kinds, and required capabilities. Draft review and every publication path use the broker's metadata-only compatibility assessment; runtime sends the same declaration to direct credential-id resolution immediately before execution. Graph `credentialBindings` are canonical. The legacy credential-request endpoint records no binding and identifies its result as non-persistent. -Create, rotate, and replace requests are write-only. API responses contain configuration and status metadata but never stored values. +The dashboard accepts secret values only on create, rotate, and replace requests. Responses contain configuration, scope, status, key-version, and validation metadata but never stored values. Access-event rows contain identifiers, binding keys, capabilities, outcomes, and denial reasons; they never contain secret material. -Create requests explicitly declare kind, scope, capabilities, and an allowlist (empty for project credentials). Runtime validation bounds names, identifiers, capabilities, list counts, and secret size (64 KiB UTF-8). Malformed arrays, unknown mutation fields, and control characters are rejected rather than coerced. +The Settings Integrations catalog exposes this broker as its first standard card. The card derives unavailable, ready/unconfigured, and configured states from backend health and project-visible metadata; **Manage** opens a project-aware detail view without rendering a secret, request body, or raw server error. Allowlisted non-owner projects can understand and use compatible global credentials but see management actions disabled. -Every lifecycle mutation includes `expectedVersion`. The only mutable descriptive field is the bounded name; kind and management ownership remain immutable. Restrictions may remove allowlisted projects or capabilities but cannot add them. Project-to-global promotion is the explicit scope expansion and requires managing-project authority, `confirmScopeExpansion: true`, the current version, and an allowlist of existing projects that retains the managing project. Current-version repeated revocation is idempotent; stale versions conflict. +Create controls require deliberate capability selection and explicit project or global scope. Global allowlists retain the management owner, and scope-expanding creation or promotion is confirmed. Rename, test, rotation/replacement, restriction, promotion, and revocation report typed inline status, disable overlapping actions, and refresh after stale-version conflicts. Destructive and scope-expanding actions use keyboard-operable confirmation dialogs with focus restoration. + +All create, rotate, and replacement fields are controlled write-only inputs. They are never hydrated from metadata and are cleared after every submission outcome, project change, and component teardown. Credential metadata drafts and browser stores do not receive secret values. + +Management inputs are validated at runtime rather than trusted from TypeScript types. Create requests must explicitly declare kind, scope, capabilities, and an allowlist (an empty array for project credentials). Names, kinds, binding keys, project ids, capabilities, and list counts are bounded; malformed arrays, unknown mutation fields, and control characters are rejected instead of being silently coerced. A stored value is limited to 64 KiB of UTF-8 data. Global allowlists must explicitly retain the management owner. + +Every lifecycle mutation carries `expectedVersion`. Successful name updates, validation tests, rotations/replacements, promotions, restrictions, and first-time revocations increment the version. A repeated revoke against an already-revoked credential at its current version is an idempotent no-op; stale requests return a conflict. Metadata updates may change only the bounded display name, so kind and management ownership remain immutable. + +Restriction is monotonic: it may remove allowlisted projects or capabilities but cannot add either. Project-to-global promotion is the explicit scope expansion and requires the managing project, a current version, `confirmScopeExpansion: true`, an allowlist containing the managing project, and project IDs that already exist. ## Runtime redaction boundary -Node-flow credentials exist in plaintext only for the active node attempt. Exact resolved values are replaced with `[REDACTED]` before provider responses, HTTP bodies, retry errors, external-effect payloads, diagnostics, invocation messages, attempts, node outputs, or run summaries are stored. Credential IDs and non-secret metadata remain available for auditability. +Node-flow execution resolves credential values only for the active node attempt. Before any provider response, HTTP body, retry error, external-effect payload, diagnostic, invocation message, attempt, node output, or run summary is persisted, the runtime replaces exact resolved values with `[REDACTED]` in addition to masking secret-shaped keys. Credential IDs and non-secret metadata remain available for audit and attempt correlation. -The same redactor protects provider activity and raw usage telemetry. Temporary credential references are cleared after the attempt and are never logged as redaction input. Custom-node outputs, stderr logs, and diagnostics follow the same rule. +Provider activity persistence uses the same invocation-scoped redactor, including raw usage telemetry and provider session identifiers. Temporary credential references are cleared after each attempt and are never included in redaction logs or diagnostics. Custom-node containers apply the equivalent policy to structured output, stderr logs, and diagnostics before returning control to the flow runtime. -Authorization is rechecked after decryption. Concurrent revocation, rotation, restriction, promotion, or rebinding clears the plaintext buffer and causes a retry or denial instead of returning stale access. +Resolution authorization is checked both before and after decryption. If a credential is revoked, rotated, restricted, promoted, or rebound while a read is in flight, the plaintext buffer is cleared and the broker denies or retries against the current version; stale authorization is never returned to the caller. ## Encryption and key custody -The SQLite secret store uses AES-256-GCM envelope encryption with a unique data key, payload nonce, and key-wrapping nonce for every write. Credential ownership and workspace context are authenticated. SQLite stores ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions—not root keys. +The SQLite secret store uses AES-256-GCM envelope encryption. Each write generates a unique 256-bit data key, payload nonce, and key-wrapping nonce. Credential ownership and workspace context are authenticated as additional data. SQLite stores only ciphertext, authentication tags, wrapped keys, nonces, and key identifiers/versions. + +Root keys are never stored in SQLite or a project checkout. The normal loopback dashboard automatically provisions one raw 32-byte root key at `~/.code-ux/security/credential-root.key`. Its dedicated parent directory is `0700` and the regular file is `0600`. Creation uses an exclusive atomic install, durable filesystem synchronization where supported, and concurrent startup convergence so restarts recover the identical key. Before creation or access, every custody-path component from the Code UX home through the key parent is inspected without following symbolic links; a symbolic-link or non-directory ancestor fails closed before a redirected key can be provisioned. Existing symbolic links, non-files, malformed keys, permissive modes, or unexpected ownership are never repaired automatically; credential operations fail closed with metadata-only setup guidance. -The trusted loopback dashboard automatically provisions one raw 32-byte root key at `~/.code-ux/security/credential-root.key`, with a `0700` parent and `0600` regular file. Provisioning is exclusive, atomic, durable where filesystem synchronization is supported, and safe across concurrent startup. Every custody-path component from the Code UX home through the key parent is inspected without following symbolic links, so a symbolic-link or non-directory ancestor fails closed before a redirected key can be provisioned. Symbolic links, non-files, malformed content, permissive modes, and unexpected ownership fail closed and are not repaired automatically. +Automatic local-file custody is limited to the non-server dashboard with local authentication, loopback binding, and remote credential management disabled. Electron's process provider remains first priority and continues to use OS `safeStorage`. Explicit `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms` configuration takes priority over automatic custody; setting `CODE_UX_CREDENTIAL_KEY_FILE` alone remains compatible with the mounted-file provider. Unknown values and an explicit `local-file` selection are rejected. Dashboard-disabled headless operation, server mode, authenticated dashboards, non-loopback bindings, and remote credential-management deployments do not auto-provision a local key. -Automatic local-file custody is disabled for server mode, dashboard-disabled headless operation, authenticated or non-loopback dashboards, and remote credential management. Electron remains first priority and persists only an OS-protected blob. Explicit `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms` configuration takes priority; `CODE_UX_CREDENTIAL_KEY_FILE` alone remains a compatible mounted-file selection. Unknown values and explicit `local-file` selection are rejected. If secure key material is unavailable, credential operations fail closed; there is no plaintext fallback. +For mounted-file custody, `CODE_UX_CREDENTIAL_KEY_FILE` identifies a regular, owner-only mounted file whose contents are an exact base64 or hexadecimal encoding of 32 bytes. Oversized or permissively decodable key files are rejected. The environment variable contains a path, not key material. Keep the mount readable only by the Code UX process and outside the project workspace. + +Electron serializes first-use root-key creation, persists only the OS-protected blob through an atomic owner-only file replacement, and refuses credential operations when `safeStorage` is unavailable. Vault and KMS adapters validate 32-byte caller-owned key material and report the active key id/version in health results. No provider silently falls back to plaintext or an insecure locally derived key. + +| Deployment boundary | Root-key custody | Provisioning behavior | +| --- | --- | --- | +| Normal CLI dashboard on loopback with local authentication | Owner-only file under the user-home Code UX security directory | Automatically created on first use and reused after restart. A normal local dashboard user does not mount or configure a key file. | +| Electron desktop | Operating-system `safeStorage` | Automatically creates and persists only the OS-protected blob; unavailable `safeStorage` blocks credential operations. | +| Dashboard-disabled headless, server mode, authenticated dashboard, non-loopback binding, or remote credential management | Explicit mounted file, Vault, or KMS provider | Never auto-provisions local custody. Setup and recovery fail closed until the configured provider reports available, secure key identity and version metadata. | ## Recovery and rotation -Back up root keys separately from `app.db`; the database alone cannot recover credentials. Local dashboard backups must include `~/.code-ux/security/credential-root.key` with owner-only handling. Creation, rotation/replacement, and promotion commit ciphertext and metadata atomically. Version compare-and-swap protects every lifecycle mutation and permits only one overlapping value change to commit. Revocation also wins against an in-flight resolution while retaining audit metadata. +Back up root keys independently from `app.db`. For the normal local dashboard, back up `~/.code-ux/security/credential-root.key` while preserving owner-only handling; for external providers, retain every referenced key version. Losing a required key version makes its ciphertext unrecoverable by design. Restoring only SQLite is insufficient. + +Credential creation commits metadata and its first envelope in one SQLite transaction. Rotation/replacement and promotion likewise commit the new envelope, metadata, version, and rotation record atomically. Compare-and-swap guards apply to every lifecycle mutation so losing callers must refresh metadata and retry instead of overwriting newer state. Root-key providers must retain old key IDs and versions until envelopes are rewrapped. Revocation wins against in-flight resolutions and preserves audit metadata. -Lifecycle success and denial audits carry correlation IDs, credential IDs, and policy metadata only. Validation records `valid`, `invalid`, or `unavailable` without exposing tested values or cryptographic internals. +Lifecycle successes and denials emit correlation-aware automation audit records containing credential IDs and policy metadata only. Validation updates report `valid`, `invalid`, or `unavailable` without including tested values or low-level cryptographic errors. Custom dashboards use a stricter metadata-only consumer boundary. Dedicated slot declarations define allowed kinds and required capabilities, while separate draft and immutable-revision binding columns store credential IDs. Binding review delegates to the broker's compatibility assessment and never resolves plaintext. Required or invalid bindings stop validation before workspace creation and are rechecked before publication. Credential values and binding IDs are excluded from generated dashboard artifacts, Docker configuration, validation output, generic REST/MCP responses, and iframe messages; only the dedicated binding-management response may expose IDs with non-secret metadata. -Legacy global records use their first valid allowlisted project as the migrated management owner; verify that owner before expanding an old global allowlist. +Existing global credentials created before management ownership was stored are migrated with their first valid allowlisted project as the management owner. Operators should verify that owner before expanding a legacy global credential's allowlist. + +## API surface + +Project-scoped routes live under `/api/projects/:projectId/credentials`. Supported operations are create, bounded-name update (`PATCH /:credentialId`), bind, metadata-only compatibility assessment, test, rotate, replace, revoke, promote, and restrict. Compatibility evaluates key-backend readiness, configuration, active status, project access, allowed kinds, and all required capabilities without resolving plaintext. A backend is ready only when it is available and secure and reports both a non-empty key ID and a key version; missing key identity metadata produces the stable `backend_unavailable` compatibility issue. List, compatibility, health, and mutation responses return metadata or policy results only. Existing dashboard authentication and remote credential-management guards apply before these routes. -## Dashboard API +Runtime validation failures return `400`, project/management denials return `403`, compare-and-swap conflicts return `409`, invalid encrypted state returns `422`, and unavailable key custody returns an actionable `503` response. -Credential management uses project-scoped dashboard routes. The API includes create, bounded-name update, bind, metadata-only compatibility assessment, test, rotate, replace, revoke, confirmed promotion, and monotonic restriction. Compatibility checks backend readiness, configured/active state, project access, allowed kinds, and all required capabilities without reading plaintext. Backend readiness requires an available, secure backend with a non-empty key ID and a reported key version; missing identity metadata produces `backend_unavailable`. List, health, compatibility, and mutation responses never contain secret values; secrets are accepted only by create, rotate, and replace operations. +## Troubleshooting without disclosure -Validation failures return `400`, project/management denials return `403`, concurrent-write conflicts return `409`, invalid encrypted state returns `422`, and unavailable key custody returns `503` with a safe recovery message. +- If custody is unavailable, inspect the metadata-only credential health or readiness result and the configured provider name. For the normal loopback dashboard, verify ownership, file type, and owner-only modes on the existing Code UX security path; for Electron, restore OS `safeStorage`; for headless or remote operation, restore the configured mount, Vault, or KMS version. Never paste, print, regenerate over, or move root-key material into a repository to diagnose the failure. +- If a mutation reports a stale `expectedVersion`, refresh credential metadata and review the newer scope, capabilities, validation state, and status before retrying. Do not reuse the rejected request blindly and do not bypass the comparison check. +- If encrypted rows exist but their key version is unavailable, restore the exact retained provider version before starting runners. Replacing it with a new key does not decrypt old envelopes; restore from the independent custody backup or recover the affected credential through the supported replacement workflow after the runtime is ready. diff --git a/docs-web/operations/server-mode.md b/docs-web/operations/server-mode.md index 27ba8c0fa4..c02e9eb262 100644 --- a/docs-web/operations/server-mode.md +++ b/docs-web/operations/server-mode.md @@ -1,29 +1,249 @@ -# Authenticated Headless Server Mode +# Secure Headless Server Mode -Code UX separates MCP bearer access from the authenticated dashboard administrative API. Remote dashboard/API deployments must use digest-backed service identities or terminate OIDC at a trusted reverse proxy; loopback desktop operation remains a trusted local boundary. +Server mode runs Code UX as an authenticated MCP HTTP control plane without binding the dashboard UI, dashboard REST routes, dashboard realtime websocket, terminal websocket, or static dashboard assets. Use it for headless hosts, CI-adjacent automation, and cluster worker control planes where clients connect over Streamable HTTP instead of launching Code UX over stdio. -## Identity and authorization +Server mode is different from ordinary `--headless` mode: -Set `CODE_UX_DASHBOARD_AUTH_MODE=service_token` and provide `CODE_UX_SERVICE_IDENTITIES_JSON` entries containing `id`, `displayName`, SHA-256 `tokenSha256`, `roles`, explicit `projectIds`, and `enabled`. Workers send the bearer through `CODE_UX_WORKER_AUTH_TOKEN` and may assert the matching identity with `CODE_UX_WORKER_SERVICE_ID`. +| Mode | Dashboard | MCP HTTP | Token behavior | +| --- | --- | --- | --- | +| Default dashboard mode | Enabled | Enabled by default | Uses an explicit token or the generated user token in `~/.code-ux/security.json`. | +| `--headless` / `--no-dashboard` | Disabled | Uses normal MCP HTTP enablement rules | Preserves local-development behavior and can use the generated user token when HTTP is enabled. | +| `--server-mode` / `CODE_UX_SERVER_MODE=true` | Disabled | Enabled by default | Requires an explicit MCP HTTP bearer token with at least 32 bearer-safe characters. | -Alternatively, set `CODE_UX_DASHBOARD_AUTH_MODE=trusted_proxy`, configure `CODE_UX_TRUSTED_PROXY_SECRET`, terminate/validate OIDC at the proxy, strip client identity headers, and inject trusted principal, role, and project headers. Authenticated remote traffic requires TLS (`X-Forwarded-Proto: https`) unless insecure HTTP is explicitly enabled for an isolated test. +## Threat Model -Roles are `credential_admin`, `automation_author`, `automation_publisher`, `automation_runner`, and `viewer`. Credential routes additionally require `CODE_UX_REMOTE_CREDENTIAL_MANAGEMENT=true`; enabling it without a healthy secure key provider makes readiness fail. Host/origin checks, no-store responses, and administrative rate limits remain active. +MCP bearer access remains a runtime-wide control-plane identity. The dashboard administrative API has a separate authenticated-headless boundary with project-scoped roles; do not treat an MCP bearer as a dashboard service identity. -The `credential_admin` role can still read administrative readiness, audit export, and SLO metrics while remote credential management is disabled. The feature flag gates credential-management and credential-health routes only. +## Authenticated Dashboard API -## Probes, audit, and SLOs +Remote dashboard/API operation is fail-closed. Setting a non-loopback `DASHBOARD_HOST` without an explicit authentication mode defaults the API to `service_token`, so unconfigured callers receive `401`/`403` instead of inheriting desktop access. Loopback desktop mode remains `local`. -`/health` is liveness. `/ready` also checks credential-key recovery, the audit store, and distributed-runner identities and returns `503` when required components are unavailable. If encrypted credential rows exist and their key cannot be recovered, startup aborts before listeners bind. Server mode never auto-provisions local-file custody; configure `mounted-key-file`, Vault, or KMS explicitly. +Choose one boundary: -Authenticated operators can use `/api/admin/readiness`, `/api/admin/audit/export` (redacted NDJSON), and `/api/admin/metrics/slo`. Audit covers management calls, credential access, runs, attempts, approvals, and outbox delivery with correlation ids. +- `CODE_UX_DASHBOARD_AUTH_MODE=service_token`: define `CODE_UX_SERVICE_IDENTITIES_JSON` as an array of identities with `id`, `displayName`, a lowercase SHA-256 `tokenSha256`, `roles`, `projectIds`, and `enabled`. Workers may send the matching id with `--service-identity-id` or `CODE_UX_WORKER_SERVICE_ID`; the bearer remains in `CODE_UX_WORKER_AUTH_TOKEN`. +- `CODE_UX_DASHBOARD_AUTH_MODE=trusted_proxy`: terminate OIDC at a trusted proxy, set `CODE_UX_TRUSTED_PROXY_SECRET`, and have the proxy overwrite `X-Code-UX-Proxy-Secret`, `X-Code-UX-Principal-Id`, `X-Code-UX-Roles`, `X-Code-UX-Project-Ids`, and optional name/kind headers. Never forward client-supplied copies. -Baseline alerts: readiness not ready for five minutes, management 5xx above 1% or p95 above one second for ten minutes, repeated lease expiry, credential-denial spikes, outbox failure backlog, or any secret/audit check failure. Target zero unauthorized project grants, secret disclosures, and duplicate side effects. +Roles are `credential_admin`, `automation_author`, `automation_publisher`, `automation_runner`, and `viewer`. Project ids are explicit; `*` is an operator-only all-project grant. Credential routes additionally require `CODE_UX_REMOTE_CREDENTIAL_MANAGEMENT=true`. Enabling that flag without a healthy secure key provider makes readiness fail. -## Backup and recovery +The `credential_admin` role can read `/api/admin/readiness`, `/api/admin/audit/export`, and `/api/admin/metrics/slo` even when remote credential management is disabled. The feature flag gates credential creation, binding, testing, rotation, replacement, revocation, promotion, restriction, and credential-health routes; it does not disable operational readiness, audit, or SLO inspection. -Back up SQLite with WAL consistency, settings, project `.code-ux/` state, and every referenced external key version. Restore keys before databases, keep runner admission disabled, require `/ready`, then reconcile leases, approvals, audit continuity, and outbox counts. Never back up plaintext service tokens beside their digests. +TLS is assumed at the reverse proxy. Authenticated remote requests must arrive with HTTPS or a trusted `X-Forwarded-Proto: https`; `CODE_UX_ALLOW_INSECURE_HTTP=true` is limited to isolated test networks. Same-origin browser checks, no-store headers, host validation, and a 600-request/minute administrative API limiter remain active. Webhook and provider-ingress endpoints retain their dedicated authentication schemes. -Rotate service identities by overlapping new/old digests until runners authenticate with the new token. Rotate credential values through the broker so graph bindings retain ids and resolve the next version. Retain old KMS/Vault versions until envelope rewrap and restore drills pass. +Example identity generation (the JSON stores only the digest): -Rollback creates and publishes a new draft from an earlier immutable version; in-flight runs stay pinned. Recovery requeues only known-safe pre-invocation work and leaves uncertain external outcomes for attention. OIDC validation and Vault/KMS client integration remain deployment-host responsibilities, and MCP bearer authority remains broader than dashboard roles. +```bash +token="$(openssl rand -base64 48 | tr -d '\n')" +digest="$(printf '%s' "$token" | sha256sum | cut -d' ' -f1)" +# Put $token in the runner secret manager and $digest in CODE_UX_SERVICE_IDENTITIES_JSON. +``` + +Use server mode when: + +- the dashboard must not be reachable from the host +- MCP clients or workers need a stable HTTP endpoint +- a reverse proxy or private network boundary provides TLS and network admission +- operators can treat the bearer token as a secret with full runtime authority + +Do not expose the MCP HTTP listener directly to the public internet. The Node listener is HTTP; terminate HTTPS with a trusted reverse proxy, tunnel, service mesh, or load balancer when traffic leaves the host. + +## Startup + +Generate the token in the process environment or a secret manager. Do not paste real bearer values into shell history, logs, tickets, release notes, or documentation. + +```bash +export MCP_HTTP_AUTH_TOKEN="$(openssl rand -base64 48 | tr -d '\n')" + +codeux \ + --server-mode \ + --mcp-http-host 127.0.0.1 \ + --mcp-http-port 4445 \ + --mcp-http-path /mcp +``` + +For a cluster control plane behind a reverse proxy or private network interface: + +```bash +export CODE_UX_SERVER_MODE=true +export MCP_HTTP_AUTH_TOKEN="$(openssl rand -base64 48 | tr -d '\n')" +export MCP_HTTP_HOST=0.0.0.0 +export MCP_HTTP_PORT=4445 +export MCP_HTTP_PATH=/mcp +export MCP_HTTP_MAX_SESSIONS=500 +export MCP_HTTP_SESSION_TIMEOUT_MS=3600000 + +codeux +``` + +The legacy `mcp-https` names remain supported for compatibility: + +| Purpose | Preferred | Legacy-compatible | +| --- | --- | --- | +| Gateway enablement | `MCP_HTTP_ENABLED`, `--no-mcp-http` to disable outside server mode | `MCP_HTTPS_ENABLED`, `--no-mcp-https` to disable outside server mode | +| Gateway host | `MCP_HTTP_HOST`, `--mcp-http-host` | `MCP_HTTPS_HOST`, `--mcp-https-host` | +| Gateway port | `MCP_HTTP_PORT`, `--mcp-http-port` | `MCP_HTTPS_PORT`, `--mcp-https-port` | +| Gateway path | `MCP_HTTP_PATH`, `--mcp-http-path` | `MCP_HTTPS_PATH`, `--mcp-https-path` | +| Bearer token | `MCP_HTTP_AUTH_TOKEN`, `--mcp-http-auth-token` | `MCP_HTTPS_AUTH_TOKEN`, `--mcp-https-auth-token` | +| Session cap | `MCP_HTTP_MAX_SESSIONS`, `--mcp-http-max-sessions` | `MCP_HTTPS_MAX_SESSIONS`, `--mcp-https-max-sessions` | +| Idle timeout | `MCP_HTTP_SESSION_TIMEOUT_MS`, `--mcp-http-session-timeout-ms` | `MCP_HTTPS_SESSION_TIMEOUT_MS`, `--mcp-https-session-timeout-ms` | + +Server mode rejects startup when the explicit token is missing, empty, shorter than 32 characters, or contains characters outside the bearer-safe set. It does not fall back to the generated local user token. + +If `--server-mode` is combined with an explicit MCP HTTP disable flag, server mode still restores the MCP HTTP listener on the default MCP port because the server-mode contract requires authenticated remote MCP access while the dashboard stays disabled. + +## Health And Readiness + +The MCP HTTP listener serves probes without the dashboard server: + +```bash +curl --fail http://127.0.0.1:4445/health +curl --fail http://127.0.0.1:4445/ready +``` + +Use `/health` for process liveness. It only proves that the listener is up. + +Use `/ready` for runtime readiness. It reports whether the Code UX runtime finished the required startup path and can accept work. During startup, maintenance such as Docker cleanup, preview reconciliation, branch reaping, and recovery work can continue after the listener binds, so `/health` can pass before `/ready`. + +Do not include `Authorization` headers in probe logs. The probe endpoints do not require bearer credentials. + +`/ready` also reports `credentialKey`, `auditStore`, and `distributedRunner`. `/health` remains live during a key-provider outage, while `/ready` returns `503`. Startup aborts before dashboard or MCP binding when encrypted credential rows exist but their key provider cannot recover the wrapping key. Server mode never auto-provisions local-file custody. Select a provider with `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms`; mounted files use `CODE_UX_CREDENTIAL_KEY_FILE` and owner-only permissions. Vault/KMS modes require their host adapter to be configured and healthy. + +The same explicit-custody requirement applies to dashboard-disabled headless operation, authenticated dashboards, non-loopback dashboard bindings, and remote credential management. Only the trusted loopback local dashboard auto-provisions its owner-only user-home key; Electron uses OS `safeStorage`. Remote setup therefore fails closed rather than borrowing the local-dashboard key, deriving a key, or falling back to plaintext. Restore the configured mount or the exact Vault/KMS key version before enabling runners; do not copy root keys into SQLite, a project checkout, deployment logs, or diagnostic bundles. + +Authenticated operators can inspect `/api/admin/readiness`, export redacted NDJSON from `/api/admin/audit/export`, and sample `/api/admin/metrics/slo`. Audit rows include the correlation id, principal, project, action, outcome, and redacted metadata for management requests, credential access, runs, attempts, approvals, and outbox delivery. + +## Backup, Restore, Rotation, And Rollback + +Back up `~/.code-ux/app.db` with a SQLite-aware snapshot that includes/checkpoints WAL state, the settings database, project `.code-ux/` directories, and the external key-provider versions needed by every encrypted envelope. Never place plaintext service tokens or root keys in the database backup. Restore into an isolated host, restore keys first, run `/ready`, then enable runners. + +Rotate service tokens by adding the new digest, deploying the new runner secret, observing successful authenticated calls, and disabling the old identity entry. Rotate credential values through the credential rotation API; existing graph bindings keep the credential id and resolve the new version. Retain old KMS/Vault key versions until every envelope has been rewrapped and a restore drill succeeds. + +To roll back an automation, create a new draft from the earlier immutable version, review it, and publish it. In-flight runs remain pinned to their original publication. Stop runner admission before database recovery; after restore, startup recovery requeues only known-safe work and leaves unknown external outcomes in `attention_required`. + +## Baseline SLOs And Alerts + +Initial operator baselines are 99.9% authenticated management availability, p95 management latency below 500 ms, zero unauthorized project grants, zero secret disclosure, and zero duplicate outbox side effects. Alert when readiness is not ready for 5 minutes, management 5xx rate exceeds 1% for 10 minutes, p95 exceeds 1 second for 10 minutes, leases repeatedly expire, denied credential access spikes, outbox failures remain pending for 5 minutes, or any audit/secret scanning check fails. + +Local mode is intentionally a trusted loopback desktop boundary. Authenticated headless mode adds API RBAC, project scope, key readiness, durable audit, and service identities, but it is not a general multi-tenant identity platform: OIDC token validation belongs at the trusted proxy, Vault/KMS require host adapters, and MCP bearer authority remains broader than dashboard roles. + +## Client Connections + +MCP HTTP clients connect to the configured path with `Authorization: Bearer `. The first JSON-RPC request on a new Streamable HTTP session must be `initialize`; the server returns an `mcp-session-id` header that the client echoes on later calls. + +For a local CLI or dashboard-adjacent session that supports MCP HTTP, configure: + +- URL: `http://:4445/mcp` +- header name: `Authorization` +- header value: `Bearer ` + +Verify without exposing the token: + +```bash +curl --fail http://127.0.0.1:4445/health +curl --fail http://127.0.0.1:4445/ready +``` + +Then verify through the MCP client by listing tools or running a read-only management action such as listing projects. Do not use `curl -v`, shell tracing, or command transcripts that print the authorization header. + +If a local dashboard app is used only as an operator console for a separate server-mode instance, configure its MCP client entry to the server-mode URL and bearer header. The dashboard UI of the server-mode process itself remains unavailable by design. + +## Settings Synchronization + +Settings synchronization uses the `manage_settings` bundle actions: + +- `export_settings_bundle` +- `apply_settings_bundle` + +Bundles can include system, project, and sprint scopes. Metadata includes `schemaVersion: 1`, `exportedAt`, `includedScopes`, a SHA-256 `fingerprint` computed from a secret-redacted representation, and `containsSecrets`. + +Approved workflow: + +1. Export a redacted bundle from the source runtime. Export defaults to the `system` scope and redacts provider API keys, git tokens, issue-tracker tokens, login credentials, and other secret-bearing fields. +2. Review the bundle before moving it to the destination. Redacted placeholders are expected and must not be replaced in shared artifacts. +3. If project or sprint settings are required, include `scopes`, `projectIds`, and `sprintIds`. Sprint exports require the owning `projectId` so imports can normalize sprint overrides against the resolved project base. +4. Apply the bundle on the destination with `apply_settings_bundle`. The importer persists through `saveSystemSettings`, `saveProjectSettings`, and `saveSprintSettings`, so values follow the same sanitizer and override normalization as dashboard saves. +5. For partial rollout or rollback, pass `scopes` on apply to limit which bundle scopes are written. + +Secret-bearing exports and imports require the stateful settings approval flow: + +- `includeSecrets: true` on export returns secrets only after the first response asks for approval and the exact same request is repeated with `approval.confirmed: true`. +- A bundle marked `containsSecrets: true`, or one whose payload contains secret-bearing fields, is applied only after the same one-use approval flow. +- Approval is bound to the exact normalized payload, expires after 15 minutes, and is consumed after one successful execution. + +Rollback is another approved apply. Export a known-good bundle before changing a destination runtime, then apply that bundle back to the affected scopes if the rollout must be reverted. Do not rely on logs or chat transcripts as backups because redaction intentionally removes sensitive values. + +## Cluster Workers + +External workers connect to the server-mode MCP HTTP endpoint as control-plane clients. The worker process also starts a local `worker-host` runtime over stdio for execution on the worker machine. + +Start a worker with the shipped bin: + +```bash +codeux-worker \ + --server-url http://SERVER_HOST:4445/mcp \ + --auth-token "$CODE_UX_WORKER_AUTH_TOKEN" \ + --connection-key worker:build-node-01 \ + --display-name "Build node 01" \ + --project-id project-id +``` + +Equivalent environment variables: + +```bash +export CODE_UX_WORKER_SERVER_URL=http://SERVER_HOST:4445/mcp +export CODE_UX_WORKER_AUTH_TOKEN="$MCP_HTTP_AUTH_TOKEN" + +codeux-worker --connection-key worker:build-node-01 --project-id project-id +``` + +Worker config supports multi-project operation: + +- repeat `--project-id` to register eligible projects +- repeat `--active-project-id` to advertise active project focus +- use a stable `--connection-key` so reconnects update the existing registered endpoint +- set `--server-command`, repeated `--server-arg`, and `--server-cwd` only when the worker-local execution runtime needs a custom command + +Cluster behavior: + +- Registered workers are not license-capped. The active Streamable HTTP session cap defaults to 100 and can be raised for large clusters. +- Project assignments live in `project_worker_assignments`. A project can have one primary worker and any number of overflow workers. +- Active-session protection prevents runaway clients from allocating unlimited Streamable HTTP sessions. Raise `MCP_HTTP_MAX_SESSIONS` only to the capacity the server can actually operate. +- Heartbeats derive endpoint status. Stale or offline workers are excluded from new claims, and stale primary workers can be bypassed by eligible overflow workers. +- Dispatch safety depends on both `task_dispatches` and `execution_leases`. A worker must not start local execution unless the server returns a claim with a lease token. Heartbeats renew the lease while the task runs; expired leases can be claimed by another eligible worker. +- Multi-project workers claim only work for projects they are assigned to and advertise as active or eligible. + +## Token Rotation + +Safe rotation is a short planned restart unless a reverse proxy or secret manager can coordinate old/new tokens externally. + +1. Generate a new token in the secret manager. +2. Update client and worker secret references, but do not restart them yet. +3. Restart the server-mode process with the new token. +4. Restart or reconnect MCP clients and workers so they initialize new sessions with the new token. +5. Confirm `/ready` passes and clients can list tools or claim work. +6. Revoke the old token from the secret manager and remove it from local shells, process managers, and deployment manifests. + +Existing HTTP sessions authenticated with the previous token should be treated as invalid after server restart because Streamable HTTP sessions are in memory. Workers should reinitialize rather than attempting to reuse old `mcp-session-id` values. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +| --- | --- | --- | +| Startup fails with a server-mode token error | `--server-mode` or `CODE_UX_SERVER_MODE=true` is set without an explicit valid bearer token. | Set `MCP_HTTP_AUTH_TOKEN` or `MCP_HTTPS_AUTH_TOKEN`, or pass the matching CLI flag. Use at least 32 bearer-safe characters. | +| Startup fails when binding `0.0.0.0`, `::`, or a LAN address | MCP HTTP is reachable beyond loopback without an active token. | Configure an explicit bearer token and put TLS/auth network controls in front of the HTTP listener. | +| Dashboard URL is unavailable | Expected in server mode. | Use MCP HTTP clients and `/health` or `/ready`. Start a separate dashboard-mode process only when an operator UI is required. | +| HTTP returns `401 Unauthorized` | Missing `Authorization: Bearer `, wrong token, duplicate authorization headers, or a client still using the old token after rotation. | Reinstall or update the client secret, reconnect, and avoid printing headers in diagnostics. | +| HTTP returns `400` on a new MCP session | The first request was not JSON-RPC `initialize`, or `mcp-session-id` / `x-code-ux-agent` was malformed. | Let the MCP SDK initialize the session, or clear stale session state and reconnect. | +| Session cap errors appear | Too many active Streamable HTTP sessions, usually from leaked clients or a cluster larger than the default cap. | Stop stale clients, shorten `MCP_HTTP_SESSION_TIMEOUT_MS`, or raise `MCP_HTTP_MAX_SESSIONS` within server capacity. | +| Worker appears stale or offline | Heartbeats stopped, the worker process is down, network access failed, or the stable connection key changed unexpectedly. | Restart the worker with the same `--connection-key`, verify `/ready`, and check logs for bounded connection metadata. | +| Worker connects but does not claim work | No active project assignment, project not included in `--project-id` / `--active-project-id`, stale endpoint status, task executor mismatch, or no lease returned. | Confirm project assignment and worker status, then verify queued dispatches. Do not start local execution without a lease token. | +| `/health` passes but `/ready` fails | Listener is alive but runtime readiness has not completed or the server is degraded. | Wait for startup recovery to finish, then inspect structured logs. Use `/ready` for load balancer readiness gates. | +| `/ready` reports credential custody unavailable | The explicit mounted-file, Vault, or KMS provider is missing, insecure, unhealthy, or cannot return the required key version. | Keep runners disabled, inspect metadata-only readiness and provider configuration, and restore the exact provider/key version. Do not print key material or substitute a new key for encrypted rows. | +| A credential operation returns a version conflict | Another operator changed metadata, scope, capabilities, status, or encrypted value first. | Refresh the metadata-only record, review the new version, and intentionally retry with that version. Do not bypass optimistic concurrency. | +| Secret values appear in an exported settings bundle | The export was explicitly approved with `includeSecrets: true`. | Store the bundle only in approved secret storage, rotate exposed credentials if it was shared, and prefer redacted exports for review. | + +## Related Docs + +- [MCP Tools](../developer/mcp-tools.md) +- [Security Hardening](./security-hardening.md) +- [Automation Credential Security](./credential-security.md) +- [Runtime Configuration](../developer/configuration.md) diff --git a/docs-web/settings/integrations.md b/docs-web/settings/integrations.md index 67e686660b..5c43622f8b 100644 --- a/docs-web/settings/integrations.md +++ b/docs-web/settings/integrations.md @@ -1,19 +1,19 @@ # Integrations -Lists provider, git-host, issue-tracker, and read-only importer integrations and exposes manage/add actions. +Lists automation credentials, providers, git hosts, issue trackers, and read-only importer integrations and exposes manage/add actions. > Settings area: `integrations` > Dashboard documentation route: `/docs/settings-integrations` ## What This Area Is For -Lists provider, git-host, issue-tracker, and read-only importer integrations and exposes manage/add actions. This page expands the short Settings-page help text into an operator reference for deciding when to change this area, what behavior the controls affect, and what to verify after saving. +Lists automation credentials, providers, git hosts, issue trackers, and read-only importer integrations and exposes manage/add actions. This page expands the short Settings-page help text into an operator reference for deciding when to change this area, what behavior the controls affect, and what to verify after saving. Use it when you are configuring a new project, auditing inherited settings, or debugging behavior that changed after a system, project, or sprint override was saved. ## Controls And Runtime Effect -Cards show connection state, auth hints, active/configured importer status, and management entry points; host hints can import detected local settings. +Cards show connection state, auth hints, active/configured importer status, and management entry points; host hints can import detected local settings. Automation Credentials is the first catalog entry and reports secure-storage unavailable, ready but unconfigured, or configured state for the selected project. Its **Manage** action uses the same detail and back-navigation behavior as every other integration. | Control Surface | Runtime Effect | Review Before Saving | | --- | --- | --- | @@ -21,11 +21,32 @@ Cards show connection state, auth hints, active/configured importer status, and | Inherited values | Values can flow from system defaults into project and sprint behavior. | Check the source badge before assuming a value is project-specific. | | Related runtime paths | The affected service reads the saved settings during planning, dispatch, dashboard rendering, or maintenance work. | Re-run the affected workflow after changing operational settings. | +## Automation Credential Management + +Credential management is project-aware even when Settings is displaying system scope. Select a project before opening the detail view so Code UX can list only metadata visible to that project and determine whether the project has management authority. + +The create form requires an explicit name, kind, project or global scope, capability selection, and—when global scope is selected—an allowlist that retains the managing project. No capability is granted implicitly. Global creation and project-to-global promotion require confirmation because they expand access. + +Each project-managed credential supports bounded rename, metadata-only validation test, value rotation, encrypted-state replacement, monotonic access restriction, confirmed promotion, and confirmed revocation. Revocation requires typing `REVOKE` exactly; each lifecycle confirmation starts with cleared confirmation state and returns focus to the credential controls when it closes. Every lifecycle request uses the metadata version shown by the service. If another session wins the compare-and-swap update, the detail view refreshes metadata and asks the operator to review and retry instead of overwriting the newer state. + +| Workflow | What the operator supplies | What remains readable afterward | +| --- | --- | --- | +| Create | Name, kind, write-only value, explicit capabilities, and project/global policy | Metadata, configured state, validation state, scope, capabilities, and version only. | +| Update metadata | A bounded display name and current version | Updated metadata; kind and management ownership cannot be changed. | +| Rotate / replace | A new write-only value and current version | New key/version and validation metadata, never either the old or new value. | +| Test | The current version | `valid`, `invalid`, or `unavailable` plus timestamps; no tested value or low-level custody error. | +| Restrict / promote | A monotonic restriction, or a confirmed global allowlist expansion owned by the managing project | Updated non-secret policy metadata. | +| Revoke | Exact confirmation and current version | Revoked status and audit metadata; the stored value cannot be read back. | + +Secret inputs are write-only. Create, rotate, and replace fields are never populated from responses, are cleared after successful or failed submissions and project changes, and are removed with the detail view. Notices, metadata cards, browser storage, and reusable drafts contain no secret value. An allowlisted project that is not the management owner sees a **Use only** state and cannot invoke management actions. + +Unavailable key custody leaves non-secret metadata visible and disables secret-bearing changes and tests. Follow the inline custody guidance, restore secure storage, then use **Refresh**. See [Automation Credential Security](../operations/credential-security.md) for encryption, authority, recovery, and API behavior. + ## Recommended Configuration -Configure provider and importer credentials at system scope and use project overrides only for repository-specific git hosts or importer defaults. +Configure provider and importer credentials at system scope and use project overrides only for repository-specific git hosts or importer defaults. Automation credentials follow their own project-aware ownership and allowlist policy rather than Settings inheritance. -For Google Drive, link an existing host-side sync or mount directory and enable the opt-in Docker mount only for projects that need it. The mount defaults to read-only; see [Google Drive Project Mount](/docs/settings-google-drive-mount) for access, inheritance, security, and troubleshooting details. This integration does not configure Google Drive API synchronization or credentials. +For Google Drive, link an existing host-side sync or mount directory and enable the opt-in Docker mount only for projects that need it. The mount defaults to read-only; see [Google Drive Project Mount](./google-drive-mount.md) for access, inheritance, security, and troubleshooting details. This integration does not configure Google Drive API synchronization or credentials. A practical review flow is: @@ -51,11 +72,14 @@ If the saved setting does not appear to take effect: - Check for a project or sprint override that takes precedence over the system value. - Refresh the affected dashboard page if the setting controls a rendered surface. - Restart the local runtime only when the setting explicitly controls startup, listener, or process-level behavior. +- If secure custody is unavailable, keep the metadata view open, restore the deployment's supported custody provider, and use **Refresh**. Local loopback CLI/dashboard mode provisions its owner-only user-home key automatically; do not add mounted-key configuration for a normal local user. +- If a save reports stale metadata, review the refreshed record before retrying with its new version. Never copy secret fields into notes, browser storage, logs, or a repository as a workaround. ## Related Documentation -- [Settings overview](/docs/settings-overview) -- [Google Drive Project Mount](/docs/settings-google-drive-mount) -- [Dashboard Settings](/docs/user-dashboard-settings) -- [Configuration and Storage](/docs/developer-settings-reference) -- [Security Hardening](/docs/user-troubleshooting) +- [Settings overview](./index.md) +- [Automation Credential Security](../operations/credential-security.md) +- [Google Drive Project Mount](./google-drive-mount.md) +- [Dashboard Settings](../user/dashboard/settings.md) +- [Runtime Configuration](../developer/configuration.md) +- [Security Hardening](../operations/security-hardening.md) diff --git a/docs-web/user/dashboard/custom-dashboards.md b/docs-web/user/dashboard/custom-dashboards.md index 9bc9006971..5ba4672c86 100644 --- a/docs-web/user/dashboard/custom-dashboards.md +++ b/docs-web/user/dashboard/custom-dashboards.md @@ -2,59 +2,140 @@ Custom dashboards are project-scoped dashboard apps generated and revised by agents, then validated in a detached Docker runtime before publication. Use them when the built-in dashboard pages do not match the operational view a team needs, such as a project-specific release panel, sprint-health cockpit, or integration-status board. -## Workflow +The source of truth is the Code UX database. Drafts stay mutable, revisions are immutable snapshots, validation sessions record build/runtime results, and publication is a single active pointer to one validated revision. -1. Ask the Project Manager for the dashboard you want. Include the purpose, target audience, data sources, layout preferences, review criteria, and whether it should be published after validation. -2. Review the draft at `/custom-dashboards`. Drafts expose manifest JSON, generated file bundle content, source-node graph JSON, styleguide JSON, and data catalog selections. +## User Workflow + +1. Ask the Project Manager for the dashboard you want. Include the purpose, target audience, data sources, layout preferences, review criteria, and whether the dashboard should be published after validation. +2. Review the draft in the dashboard workspace at `/custom-dashboards`. The draft includes editable manifest JSON, generated file bundle content, source-node graph JSON, styleguide JSON, and data catalog selections. 3. Ask for changes or edit the draft before creating a revision. Draft edits do not change previous revisions or the currently published dashboard. -4. If the manifest declares credential slots, open the editor's **Credentials** tab. It shows bounded declarations and current non-secret metadata, and offers only active, configured, project-authorized credentials that satisfy the declared kinds and capabilities. Bind every required slot; no secret value is entered into the dashboard draft or generated code. -5. Create a revision when the draft is ready. A revision snapshots the current manifest, files, source graph, styleguide, runtime metadata, and credential-ID bindings. -6. Run detached validation. Code UX reviews bindings before it builds the revision in Docker, captures the browser-ready Vite artifact, starts a detached preview container, and health-checks the root URL. -7. Inspect validation status, logs, and the proxied preview link. Validation passes only after credential policy, install, build, artifact capture, container start, and root health checks succeed. -8. Publish the validated revision. Publication rechecks credential metadata and remains blocked unless the revision has a passed validation report. -9. Roll back by publishing an earlier passed revision, or archive the dashboard to clear its active publication while preserving history. +4. If the manifest declares credential slots, open the editor's **Credentials** tab. It shows each bounded declaration and current non-secret credential metadata, and offers only active, configured, project-authorized credentials that satisfy the allowed kinds and required capabilities. Bind every required slot; no secret value is entered into the dashboard draft or generated code. +5. Create a revision when the draft is ready. A revision snapshots the current manifest, file bundle, source graph, styleguide, runtime metadata, and credential-ID bindings. +6. Run detached validation for the revision. Code UX reviews bindings before it materializes the bundle, builds it in Docker, starts a detached preview container, and health-checks the root URL. +7. Inspect validation status, logs, and the proxied preview link. Validation passes only after credential policy, install, build, browser artifact capture, container start, and root health checks succeed. A passed validation does not publish by itself. +8. Publish the validated revision. Publication rechecks credential metadata and requires `validationStatus: "passed"` with a valid validation report. Publishing another passed revision is the rollback path. +9. Archive dashboards you no longer want active. Archiving clears the active publication and marks the dashboard archived while preserving revision and validation history. + +If validation fails, use the report and logs to create a new revision. Do not publish around the failure; the repository rejects failed, queued, running, cancelled, missing, or mismatched validation sessions before publication state changes. When a dashboard is already published, validating later drafts keeps the active published dashboard open, and validation sessions for the active published revision do not replace its published validation snapshot. + +The Credentials tab appears only when the saved manifest declares slots. Secure-backend failures and empty compatible lists link to credential management in Settings. Binding, replacement, and unbinding use the current optimistic binding revision; a concurrent edit refreshes the dashboard and requires an explicit retry instead of overwriting the other operator. Required unbinding immediately shows the draft as not ready for its next revision, while optional unbound slots remain valid. Every successful binding change refreshes validation and publication readiness. + +Credential selection and actions are keyboard accessible, restore focus after completion, and announce saving or error state. Credential IDs remain confined to the dedicated metadata-management request state and never enter manifest, generated-file, source-graph, styleguide, runtime-text, or secret-value fields. + +If secure custody is unavailable, the Credentials tab keeps existing bindings unchanged, reports metadata-only readiness, and links to Settings. Restore the supported custody provider and refresh the review; do not put a key or credential value in manifest JSON, generated files, validation logs, or project files. If a bind/unbind returns a stale binding revision, the editor refreshes declarations, candidates, bindings, and readiness, then requires an explicit retry. If validation or publication denies a formerly compatible binding, refresh review because revocation, restriction, project access, capabilities, kind policy, or custody health may have changed. + +## Agent Workflow -If validation fails, use the report and logs to create a new revision. Code UX rejects failed, queued, running, cancelled, missing, or mismatched validation sessions before publication state changes. When a dashboard is already published, validating later drafts keeps the active published dashboard open, and validation sessions for the active published revision do not replace its published validation snapshot. +Project Manager agents should use the `manage_custom_dashboards` MCP surface rather than writing generated code into `dashboard/src`. -The Credentials tab appears only for manifests with declared slots. Unavailable secure custody and empty compatible lists link to credential management in Settings. Binding changes use the current optimistic revision; a conflict refreshes the dashboard and asks for an explicit retry. Required unbinding marks the next revision as not ready, while optional unbound slots remain valid. Successful changes refresh validation and publication readiness. The controls support keyboard selection, visible focus, focus restoration, and live save/error announcements. Credential IDs stay out of manifest, file, source, styleguide, and runtime text editors, and the editor never requests or displays secret values. +Recommended sequence: -## Data Sources +1. Gather missing requirements for purpose, audience, source data, style, accessibility, and publication intent. +2. Call `data_catalog` for the project when reusing existing custom-dashboard source declarations. +3. Call `create` or `update` with a complete manifest, file bundle, source-node graph, styleguide, and runtime metadata. +4. Call `list_credential_slots` when the manifest declares slots. Select only candidate credential IDs reported compatible, then call `bind_credential` with the current `expectedBindingRevision` and complete the human-approval flow. Use `unbind_credential` before changing a bound slot's policy. +5. Call `create_revision` to snapshot the draft and binding IDs. +6. Call `validate_revision`, then poll `validation_status` and read `validation_logs` when the session is not passed. +7. Repair failures by updating the draft or binding metadata and creating a new revision. +8. Call `publish_revision` only after validation passed. Include `validationSessionId` when publishing from the session just reviewed. +9. Use `archive` only after human approval; the action follows the standard destructive-action approval flow. -Custom dashboards declare a `sourceNodeGraph` with nodes, edges, and optional metadata. Nodes have `id`, `type`, `title`, and optional JSON `config`. +## Data-Source Node Graph + +Each dashboard draft and revision can declare a `sourceNodeGraph`: + +```json +{ + "nodes": [ + { "id": "execution", "type": "project_dashboard_data", "title": "Project execution" }, + { "id": "stats", "type": "stats", "title": "Seven-day stats", "config": { "window": "7d" } } + ], + "edges": [], + "metadata": {} +} +``` + +Nodes have `id`, `type`, `title`, and optional JSON `config`. Edges have `fromNodeId`, `toNodeId`, and an optional `id`. The graph records the data the generated dashboard expects; it is also used by the in-app viewer to decide which source requests are allowed. + +Supported user-level source types: | Source type | Runtime behavior | | --- | --- | -| `project_dashboard_data`, `project_dashboard`, `dashboard_data` | Reads project execution data. | -| `stats`, `project_stats` | Reads project stats. `config.window` selects the stats window when present. | -| `telemetry`, `overview_telemetry` | Reads overview telemetry. | -| `integrations_metadata`, `integrations` | Returns only non-secret metadata declared on the source node. | -| `external_api` | Placeholder only. Arbitrary external calls are not proxied and return an unavailable-source error. | +| `project_dashboard_data`, `project_dashboard`, `dashboard_data` | Reads project execution data from `GET /api/projects/:projectId/execution`. | +| `stats`, `project_stats` | Reads project stats from `GET /api/projects/:projectId/stats`; `config.window` selects the stats window when present, otherwise `7d` is used. | +| `telemetry`, `overview_telemetry` | Reads overview telemetry from `GET /api/telemetry/overview`. | +| `integrations_metadata`, `integrations` | Returns only the non-secret metadata declared on the source node. It does not expose provider credentials or effective settings secrets. | +| `external_api` | Placeholder only in the in-app viewer. It is declared in the graph and validation bridge, but arbitrary external calls are not proxied and return an unavailable-source error. | + +Unsupported source types return an explicit unavailable-source error. Generated dashboards should handle these errors visibly instead of assuming all declared data is available. + +## REST API Surface + +Custom dashboard routes are registered with the dashboard server: + +| Method | Route | Purpose | +| --- | --- | --- | +| `GET` | `/api/projects/:projectId/custom-dashboards` | List dashboards for a project. | +| `POST` | `/api/projects/:projectId/custom-dashboards` | Create a mutable draft. | +| `GET` | `/api/projects/:projectId/custom-dashboards/data-catalog` | Return project dashboard summaries and declared source nodes. | +| `GET` | `/api/custom-dashboards/:dashboardId` | Return a dashboard plus revisions. | +| `PATCH` | `/api/custom-dashboards/:dashboardId` | Update mutable draft fields. | +| `DELETE` | `/api/custom-dashboards/:dashboardId` | Archive the dashboard and clear active publication. | +| `POST` | `/api/custom-dashboards/:dashboardId/revisions` | Create an immutable revision from the draft or supplied overrides. | +| `POST` | `/api/custom-dashboards/:dashboardId/revisions/:revisionId/validate` | Start a detached validation session. Body may include `projectId`; otherwise the server resolves it from the revision. | +| `POST` | `/api/custom-dashboards/:dashboardId/revisions/:revisionId/publish` | Publish a validated revision, optionally with `validationSessionId`. | +| `GET` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings?revisionId=...` | Review draft or revision slots, current bindings, backend health, and bounded compatible credential metadata. | +| `PUT` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` | Bind or replace one slot using `slotId`, `credentialId`, and `expectedBindingRevision`. | +| `DELETE` | `/api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings/:slotId` | Unbind one slot using `expectedBindingRevision`. | +| `GET` | `/api/custom-dashboard-validations/:sessionId` | Read validation session status and runtime metadata. | +| `GET` | `/api/custom-dashboard-validations/:sessionId/logs?tail=200` | Read bounded validation and container logs. | +| `POST` | `/api/custom-dashboard-validations/:sessionId/stop` | Stop the detached validation container. | +| `DELETE` | `/api/custom-dashboard-validations/:sessionId` | Remove a validation session after cleanup. | +| `ALL` | `/api/custom-dashboard-validations/:sessionId/proxy{*rest}` | Same-origin proxy to the detached validation runtime. | +| `ALL` | `/api/custom-dashboards/validation-sessions/:sessionId/proxy{*rest}` | Backward-compatible validation proxy route. | + +The binding routes are credential-management routes: authenticated remote callers require `credential_admin`, project access, and enabled remote credential management. Stale binding revisions return `409`; incompatible credential selection returns `403`. Publication first repeats metadata-only binding review, then applies the repository validation gate. REST and MCP denials preserve a sanitized `issues` array with slot-specific `field`, `code`, and `message` values while omitting credential IDs and values. Active publications remain the opening source of truth while later validation sessions run. + +## MCP Surface + +The dedicated MCP tool is `manage_custom_dashboards` and is available to the project-manager runtime role. It supports: + +- `list`, `get`, `create`, `update` +- `create_revision` +- `validate_revision`, `validation_status`, `validation_logs` +- `publish_revision` +- `archive` +- `data_catalog` +- `list_credential_slots`, `bind_credential`, `unbind_credential` + +Credential actions use `projectId`, `dashboardId`, `slotId`, `credentialId`, and `expectedBindingRevision`; an optional `revisionId` reviews an immutable snapshot. Bind and unbind require the normal stateful human-approval handshake. Before creating any approval fingerprint, Code UX rejects secret, header, environment, malformed approval, and other undeclared fields, then rebuilds the approval payload from only the allowed metadata. Other important fields include `sessionId`, `validationSessionId`, `title`, `description`, `manifest`, `fileBundle`, `sourceNodeGraph`, `styleguide`, `runtimeMetadata`, `tail`, and `approval`. + +The dashboard chat JSON-action bridge also understands the legacy `custom_dashboards` management domain, but agents should prefer the dedicated MCP tool when it is available. + +## Validation Runtime + +Validation sessions move through `queued`, `building`, `running`, `passed`, `failed`, or `cancelled`. -Generated dashboards should handle unavailable-source errors visibly. External API connectors are not fully available through the in-app viewer yet. +During validation, Code UX: -## Agent and API Notes +- performs metadata-only compatibility review for every bound slot and every required slot +- creates a validation session row and runtime directory under the selected project +- writes the generated bundle plus a known Vite/Preact harness +- injects a read-only `codeUxDataBridge` / `CodeUXCustomDashboard` object +- runs install and build in Docker using the resolved CLI workflow image +- persists the built Vite `dist` files on the validated revision as the published-viewer artifact +- starts a detached preview container on an allocated localhost port +- health-checks the root URL before marking the session passed +- records workspace path, log path, container id/name, host port, validation proxy path, commands, and log excerpts in runtime metadata -Project Manager agents use the `manage_custom_dashboards` MCP tool to create drafts, list credential slots, bind or unbind credential IDs, create revisions, validate revisions, inspect logs, publish passed revisions, archive dashboards, and read the data catalog. Credential mutations require the normal stateful human-approval handshake and an optimistic `expectedBindingRevision`; unsupported or secret-bearing fields are rejected before approval state is created. +Required missing bindings and bound credentials that are missing, revoked, inaccessible, unconfigured, wrong-kind, missing capabilities, or blocked by unavailable/insecure key custody fail with slot-specific issues before workspace creation. Optional unbound slots remain valid. No custom-dashboard path resolves secret plaintext: credential values and binding IDs stay out of generated source, file bundles, bridge files, Docker arguments and mounts, validation reports and logs, viewer records, iframe configuration, and browser messages. Generic response and viewer boundaries recursively redact known binding IDs from nested manifests, file content and metadata, source graphs, styleguides, runtime metadata, validation reports, and persisted viewer artifacts. Dedicated credential-binding management responses may return credential IDs and non-secret metadata so operators and agents can select them. -The same workflow is available through the dashboard REST API: +The `build` and `runtime` slot phases are bounded declarations used for review and policy only. They do not inject a secret into the build container, published artifact, iframe, MCP result, or runtime data bridge. This feature does not migrate or expose broader provider secrets. -- `GET/POST /api/projects/:projectId/custom-dashboards` -- `GET /api/projects/:projectId/custom-dashboards/data-catalog` -- `GET/PATCH/DELETE /api/custom-dashboards/:dashboardId` -- `POST /api/custom-dashboards/:dashboardId/revisions` -- `POST /api/custom-dashboards/:dashboardId/revisions/:revisionId/validate` -- `POST /api/custom-dashboards/:dashboardId/revisions/:revisionId/publish` -- `GET /api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` -- `PUT /api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings` -- `DELETE /api/projects/:projectId/custom-dashboards/:dashboardId/credential-bindings/:slotId` -- `GET /api/custom-dashboard-validations/:sessionId` -- `GET /api/custom-dashboard-validations/:sessionId/logs` -- `POST /api/custom-dashboard-validations/:sessionId/stop` -- `DELETE /api/custom-dashboard-validations/:sessionId` -- `ALL /api/custom-dashboard-validations/:sessionId/proxy{*rest}` +Stopping a validation session removes the detached container. It does not invalidate a passed revision report. Removing a validation session deletes the session row after cleanup; the revision's validation metadata remains the publication gate. -Remote callers to credential-binding routes require the credential-administrator role, project access, and enabled remote credential management. Required missing bindings and bound credentials that are revoked, inaccessible, unconfigured, wrong-kind, missing capabilities, or blocked by unavailable key custody fail before workspace creation and are rechecked before publication. REST and MCP publication denials include sanitized slot-specific issues without credential IDs or values. Optional unbound slots remain valid. +## Published Viewer and Rollback -Custom-dashboard binding is metadata-only: no secret is resolved, and credential values and binding IDs are excluded from generated files, bridges, Docker configuration, validation output, generic REST/MCP responses, iframe configuration, and browser messages. Generic response and viewer boundaries recursively redact known IDs from nested manifests, files, source graphs, runtime metadata, validation reports, and viewer artifacts. Dedicated binding-management responses may return credential IDs and non-secret metadata so operators and agents can select them. +The in-app viewer renders only published dashboards whose active `publishedRevisionId` points to a revision with a passed validation report. For the default `src/dashboard.tsx` draft and other TSX/Preact revisions validated through the harness, the viewer uses the persisted Vite `dist` artifact instead of the source entry file, so publication does not depend on the detached validation container still running. Generated code runs inside a sandboxed iframe document and talks to the parent app through a constrained `postMessage` bridge. The parent serves only declared source-node requests. -Published dashboards render inside a sandboxed iframe. For TSX/Preact drafts such as the default `src/dashboard.tsx` bundle, the viewer uses the persisted validation artifact instead of the source entry file, so it can open after publication even when the detached validation preview is gone. The frame can request only declared source nodes through the Code UX bridge, parent and frame handlers verify the expected window source, and the parent dashboard returns data through same-origin API calls. +Rollback is publish-based: select an earlier passed revision and publish it again. The publication pointer moves back to that immutable revision. Archive is the safe removal path when no dashboard should be active; it clears the publication pointer while preserving history. diff --git a/docs-web/user/dashboard/node-flows.md b/docs-web/user/dashboard/node-flows.md index 5668b99750..f77885f78b 100644 --- a/docs-web/user/dashboard/node-flows.md +++ b/docs-web/user/dashboard/node-flows.md @@ -1,42 +1,44 @@ -# Node Flows +# Node Flows Dashboard The **Nodes** page (`/nodes`) is the project-scoped backend authoring, publication, and operations surface for canonical node flows. No selected project means no flow library, credential metadata, publications, or durable run history are requested. -## Library, Drafts, And Migration +## Library, drafts, and migration -The flow library contains backend drafts and publications owned by the active project. Saves include the loaded draft revision, so a concurrent edit produces a visible conflict and never overwrites newer work. +The library loads through `GET /api/projects/:projectId/node-flows`. Drafts are created through `POST /api/projects/:projectId/node-flow-drafts` and saved through revision-checked `PATCH /api/node-flow-drafts/:flowId`. A stale revision produces a visible conflict and never overwrites newer work. -The former browser graph at `codeux:nodes-canvas:v1` is eligible for one import into the selected project. The bridge maps `trigger`, `agent`, and `task` to registered `input`, `set_fields`, and `provider_prompt` definitions, retains `condition` and `output`, and remaps their ports before creating an **Imported Nodes Canvas** draft. A failed import remains retryable and does not block the normal library load; only success removes the old value and records the marker. +The former browser graph at `codeux:nodes-canvas:v1` is eligible for one import into the selected project. The bridge maps `trigger` to `input`, `agent` to `set_fields`, and `task` to `provider_prompt`; `condition` and `output` remain governed definitions, ports are remapped, and legacy configuration is retained as non-secret metadata. Code UX creates an **Imported Nodes Canvas** backend draft and only then removes the legacy value and records a project-specific marker. A failed import remains retryable and is isolated from normal library loading, while a successful marker prevents duplicates. -## Registry-Driven Editing And Credentials +## Registry-driven editing and credentials -The registry list returns flat versioned palette summaries. Selecting a definition loads the full manifest from the node-type detail endpoint, including nested `ui.widgetSchema`, configuration schema, policies, documentation, and deprecation metadata. The inspector renders from that full contract. Graphs reference a definition version and store non-secret configuration and credential ids; they do not contain custom-node source or resolved credentials. +`GET /api/node-flow-catalog` returns flat versioned palette summaries. `GET /api/node-flow-catalog/:nodeType` returns the full `NodeDefinitionManifest`, including nested `ui.widgetSchema`, configuration schema, policies, documentation, and deprecation metadata. The inspector renders from that full contract. Graphs reference a definition version and store non-secret configuration and credential ids; they do not contain custom-node source or resolved credentials. Credential slots use the versioned definition's allowed kinds and required capabilities to offer project-visible credential metadata. Only active, configured credentials with project access and a healthy secure backend are selectable; unavailable entries explain the operator-facing reason without exposing secret or key-custody details, and an empty compatible set links directly to **Settings → Integrations**. -Selecting, replacing, or removing a credential updates only that slot in the node's canonical `credentialBindings` and immediately saves the complete draft through the current optimistic revision. The dashboard then adopts the canonical flow revision and refreshes governed review. Saving, saved, policy-denial, and error states are announced. A revision conflict loads the latest draft, preserves the selected slot workflow and sibling edits, and requires the operator to choose again rather than replaying the stale mutation. Credential plaintext remains behind the broker and is excluded from graph data, component state, and browser output. +Selecting, replacing, or removing a credential updates only that slot in the node's canonical `credentialBindings` and immediately saves the complete draft through the current optimistic revision. The dashboard then adopts the canonical flow revision and refreshes governed review. Saving, saved, policy-denial, and error states are announced. A revision conflict loads the latest draft, preserves the selected slot workflow and sibling edits, and requires the operator to choose again rather than replaying the stale mutation. Credential plaintext remains behind the broker and is excluded from graph data, component state, browser output, logs, and documentation examples. + +Removing a required binding is allowed as a draft edit but immediately changes review and publication readiness to blocked; removing an optional binding remains valid. Publication is denied for required missing bindings and for credentials that become unavailable, unconfigured, revoked, inaccessible to the project, wrong-kind, or short of a required capability. Runtime repeats compatibility against the immutable publication, so a later custody outage, restriction, revocation, or rebinding denies execution instead of using a stale dashboard decision. The complete governed built-in set currently registered with executable handlers is `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, `condition`, `switch`, `foreach`, `merge`, `delay`, `approval`, `email_draft`, `email_send`, `execute_subflow`, `webhook_trigger`, and `output`. -Registered custom definitions can execute only when their validated versioned manifest, immutable artifact, and custom-node runtime are available. Raw legacy kinds are translated during import rather than executed directly. Unknown or unregistered types, mockup entries, and definitions marked non-executable remain planned or unavailable. +Registered custom definitions can execute only when their validated versioned manifest, immutable artifact, and custom-node runtime are available. Raw legacy `trigger`/`agent`/`task` kinds are translated by the browser import bridge rather than executed directly. Unknown or unregistered types, mockup entries, and definitions marked non-executable are planned or unavailable definitions. -## Governance And Publication +## Governance and publication Draft review provides structural validation, policy findings, requested permissions, side-effect review, and a non-executing dry run. Publication requires the current draft revision, a valid governed review, and all required credentials. Each publication is an immutable snapshot; comparison and rollback operate on versioned history, and only a pinned or latest-published version can execute. -## Durable Debugger And Scheduling +## Durable debugger and scheduling + +The debugger reads persisted flow runs, node runs, attempt history, retry classifications and decisions, approval records, invocation links, timing, and redacted input and output. Pending approvals expose keyboard-accessible **Approve & continue** and **Reject** actions. A decision continues or terminates the same pinned run, and repeated decisions return its current durable state without duplicating a governed attempt or external send. The debugger also supports cancellation and safe retry. -The debugger reads persisted flow runs, node runs, attempt history, retry classifications and decisions, approval records, invocation links, timing, and redacted input and output. Pending approvals offer **Approve & continue** and **Reject** actions. A decision continues or terminates the same pinned run, and repeated decisions return its current durable state without duplicating a governed attempt or external send. The debugger also supports cancellation and safe retry. +Foreach runs persist one downstream node run and attempt sequence per deterministic logical item. Item inputs, retries, cancellation, approvals, and side-effect identity survive restart; concurrency is bounded by the node configuration. Empty collections select the explicit `empty` branch and persist the item branch as skipped, while oversized collections fail instead of being truncated. -Use the [Scheduler](./scheduler.md) to target a pinned or latest-published version. A flow can also be attached to a project agent preset as a reusable skill; removing the attachment does not remove the flow, publications, schedules, or run history. +The layout stacks on small screens, preserves keyboard-visible focus, labels loading/error/empty states, and bounds long histories and JSON output with scrolling. Rendered run payloads redact secret-shaped keys such as `apiKey`, `authorization`, `cookie`, `password`, `secret`, and `token`. The run debugger lists durable approvals beside node attempts. A pending item offers **Approve & continue** and **Reject** actions. The decision applies to the same pinned run, and repeated clicks return its current state without sending an approved external effect twice. -Foreach executes the selected downstream branch once per deterministic logical item. The node's `concurrency` setting bounds active items, `maxItems` rejects oversized inputs, and zero items explicitly select the `empty` branch. Item-specific inputs, retries, cancellation, approvals, and external-effect identity are persisted so restart continuation does not replay completed items or duplicate sends. - -## Agent Attachment +## Agent attachment A selected project loads its agent presets, and selecting a flow loads that flow's current bindings. The inspector exposes only agent names and attachment skill metadata; it never renders agent instructions, custom source, credential values, or decrypted material. @@ -44,9 +46,9 @@ Attaching and detaching use the governed node-flow attachment routes, then refre A flow can be attached to a project agent preset as a repeatable skill with a name and description. Detaching removes only that binding; the flow, its graph, schedules, and run history remain in the project. -## Scheduling +Scheduling is entered through `/scheduler` and targets a pinned or latest-published version. A flow can also be attached to a project agent preset as a reusable skill; removing the attachment does not remove the flow, publications, schedules, or run history. -Use the [Scheduler](./scheduler.md) page to run a saved node flow once or on a recurrence. Scheduled node-flow entries select a project-owned flow and may include optional JSON object input. Pause, resume, failure handling, and due-run behavior match the normal scheduler model. +Scheduled node-flow entries select a project-owned flow and may include optional JSON object input. Pause, resume, failure handling, and due-run behavior match the normal scheduler model. ## Graph v2 boundary diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index cfd54d82db..5581313c90 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -92,6 +92,7 @@ - [Chat Provider Integrations](./settings/chat-provider-integrations.md) - [Settings Reference](./settings/index.md) - [Google Drive Project Mount](./settings/google-drive-mount.md) + - [Integrations](./settings/integrations.md) - [Project Context](./settings/project-context.md) - [System Runtime](./settings/system-runtime.md) - [Provider Credentials](./settings/provider-credentials.md) diff --git a/docs/architecture/custom-dashboard-foundation.md b/docs/architecture/custom-dashboard-foundation.md index 57aeb569b4..ba3a63d42d 100644 --- a/docs/architecture/custom-dashboard-foundation.md +++ b/docs/architecture/custom-dashboard-foundation.md @@ -15,6 +15,12 @@ Primary records: Dashboard status values are `draft`, `validating`, `validated`, `published`, `rejected`, and `archived`. Validation status values are `queued`, `building`, `running`, `passed`, `failed`, and `cancelled`. +### Feature baseline and bounded addition + +Repository history provides the negative baseline for this subsystem: at the pre-feature `dev` commit `716ac2c55`, `CustomDashboardManifest` had no `credentialSlots`, and mutable dashboard and immutable revision records had no `credentialBindings` or binding revision. The implemented change is intentionally limited to bounded manifest declarations, credential-ID bindings in dedicated draft/revision columns, metadata-only compatibility review, optimistic binding mutation, and validation/publication gates. It does not migrate provider secrets and it does not add custom-dashboard secret injection. + +Declarations are normalized and bounded for count, slot ID, label, phase (`build` or `runtime`), allowed kinds, and required capabilities. Bindings contain only `slotId` and `credentialId`; generic draft/revision writes cannot set them, and immutable revisions snapshot them. The phase is policy metadata for review and validation, not permission to inject a value into build or runtime artifacts. + ## Persistence SQLite tables are created in both the initial schema and startup migrations: @@ -61,6 +67,8 @@ Validation flow: Validation does not publish or activate dashboards. A successful run only marks the revision validation status as `passed`; publication remains gated by `publishRevision`. REST and MCP publication re-run metadata-only binding review immediately before calling the repository, then require either a revision already marked `passed` with a valid report or an explicit passed validation session for that revision. Failed binding review, queued/running/cancelled validation, missing state, and cross-revision sessions are rejected before the publication pointer changes. +No custom-dashboard service resolves credential plaintext. Build workspaces, generated files and Vite artifacts, Docker arguments/mounts/environment, validation reports/logs, generic REST/MCP records, viewer configuration, iframe `srcdoc`, data-bridge payloads, and `postMessage` traffic receive neither credential values nor binding IDs. Only the dedicated metadata-management response may return binding IDs alongside non-secret credential metadata. + ## REST and MCP Surface Dashboard HTTP routes live in `src/server/custom-dashboard-routes.ts` and are registered with the existing dashboard route groups. They are thin adapters over `CustomDashboardRepository` and `CustomDashboardValidationService`: diff --git a/docs/architecture/node-flow-builtins-and-security.md b/docs/architecture/node-flow-builtins-and-security.md index 4cb15afeba..18174020be 100644 --- a/docs/architecture/node-flow-builtins-and-security.md +++ b/docs/architecture/node-flow-builtins-and-security.md @@ -19,6 +19,12 @@ The governed built-in catalog extends publication-based node-flow execution with The existing `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, and `output` nodes retain their previous contracts. Typed manifest ports identify branch handles, many-valued merge inputs, and trigger outputs. Branch routing only runs a node when at least one incoming edge is active, allowing merges to join a selected path without treating an unselected sibling as a failure. +## Credential-bound execution + +Versioned definition manifests declare credential slots by required state, allowed kinds, and required capabilities. Draft review and publication use metadata-only broker compatibility; the canonical graph stores only slot-to-credential-ID bindings. Required missing bindings and bindings denied for unavailable custody, configuration, status, project access, kind, or capability stop publication. Optional unbound slots remain valid. + +At runtime, the immutable published graph is revalidated and the broker repeats authorization immediately before resolving a value for the active attempt. A revoked, restricted, rebound, wrong-kind, insufficiently capable, or unavailable credential fails the attempt closed. Exact resolved values are redacted from built-in output, invocation/attempt records, diagnostics, retries, HTTP/provider responses, and external-effect persistence; neither publication nor MCP inspection injects or returns plaintext. + Foreach assigns deterministic logical-item identities from the published node id and item index. Each downstream node run and numbered attempt persists that identity together with the item-specific input. The `concurrency` setting defaults to one and is capped at 64; `maxItems` is a rejection bound rather than a truncation rule. A zero-item input selects `empty`, while the `items` branch is persisted as skipped. Per-item failures retain their own retry history, successful siblings are not replayed during approval or restart continuation, and aggregated output preserves input order. ## Governed egress diff --git a/docs/architecture/node-flows.md b/docs/architecture/node-flows.md index d55a665f41..4a20a15ffe 100644 --- a/docs/architecture/node-flows.md +++ b/docs/architecture/node-flows.md @@ -43,6 +43,14 @@ The dashboard uses the same backend-owned Graph v2 record as the runtime. The se `dashboard/src/v2/lib/nodes-canvas-state.ts` remains only a compatibility and pure graph-state layer. Its legacy browser graph can be imported once into a project draft. The adapter translates `trigger`/`agent`/`task` into registered `input`/`set_fields`/`provider_prompt` nodes, remaps legacy handles to governed ports, and retains non-secret canvas metadata. Import failure is isolated from the normal library load; only a successful draft creation removes the old graph key and records the project marker. Browser storage is not the workflow source of truth. +### Credential binding lifecycle + +Each versioned node definition is the slot-policy authority: every slot declares whether it is required, its allowed credential kinds, and all required capabilities. The picker lists project-visible metadata, then filters each candidate through secure-backend readiness, configured/active state, project access, kind, and capability compatibility. It never resolves a value. + +`NodeFlowNode.credentialBindings` is the only persisted binding source. Selecting, replacing, or unbinding a credential changes the matching `{ slot, credentialId }` entry in the complete canonical graph and saves with the current `draftRevision`. The dashboard adopts the returned graph and revision, then refreshes governed review. A `409`-style revision conflict refreshes the latest draft and requires a deliberate retry; it never replays a stale binding over sibling changes. + +Required unbound slots and any bound credential denied by backend readiness, configuration, active status, project access, allowed kind, or required capabilities block publication. Optional unbound slots do not. Runtime revalidates the immutable publication and repeats the same policy immediately before direct credential-ID resolution, so revocation, restriction, rotation/rebinding races, missing custody, or incompatible policy deny the node attempt rather than injecting stale plaintext. Graph, review, publication, MCP, and dashboard payloads contain IDs and non-secret policy metadata only. + ## Runtime `NodeFlowRuntimeService.runFlow(projectId, flowId, input, options)` resolves an explicit pinned or latest-published snapshot, revalidates that immutable graph, claims a durable lease, and executes nodes in topological order. See [Node Flow Durable Execution](./node-flow-durable-execution.md) for queue, retry, lease, recovery, quota, and redaction guarantees. diff --git a/docs/dashboard/custom-dashboards.md b/docs/dashboard/custom-dashboards.md index 91e340f6d6..5ba4672c86 100644 --- a/docs/dashboard/custom-dashboards.md +++ b/docs/dashboard/custom-dashboards.md @@ -22,6 +22,8 @@ The Credentials tab appears only when the saved manifest declares slots. Secure- Credential selection and actions are keyboard accessible, restore focus after completion, and announce saving or error state. Credential IDs remain confined to the dedicated metadata-management request state and never enter manifest, generated-file, source-graph, styleguide, runtime-text, or secret-value fields. +If secure custody is unavailable, the Credentials tab keeps existing bindings unchanged, reports metadata-only readiness, and links to Settings. Restore the supported custody provider and refresh the review; do not put a key or credential value in manifest JSON, generated files, validation logs, or project files. If a bind/unbind returns a stale binding revision, the editor refreshes declarations, candidates, bindings, and readiness, then requires an explicit retry. If validation or publication denies a formerly compatible binding, refresh review because revocation, restriction, project access, capabilities, kind policy, or custody health may have changed. + ## Agent Workflow Project Manager agents should use the `manage_custom_dashboards` MCP surface rather than writing generated code into `dashboard/src`. @@ -128,6 +130,8 @@ During validation, Code UX: Required missing bindings and bound credentials that are missing, revoked, inaccessible, unconfigured, wrong-kind, missing capabilities, or blocked by unavailable/insecure key custody fail with slot-specific issues before workspace creation. Optional unbound slots remain valid. No custom-dashboard path resolves secret plaintext: credential values and binding IDs stay out of generated source, file bundles, bridge files, Docker arguments and mounts, validation reports and logs, viewer records, iframe configuration, and browser messages. Generic response and viewer boundaries recursively redact known binding IDs from nested manifests, file content and metadata, source graphs, styleguides, runtime metadata, validation reports, and persisted viewer artifacts. Dedicated credential-binding management responses may return credential IDs and non-secret metadata so operators and agents can select them. +The `build` and `runtime` slot phases are bounded declarations used for review and policy only. They do not inject a secret into the build container, published artifact, iframe, MCP result, or runtime data bridge. This feature does not migrate or expose broader provider secrets. + Stopping a validation session removes the detached container. It does not invalidate a passed revision report. Removing a validation session deletes the session row after cleanup; the revision's validation metadata remains the publication gate. ## Published Viewer and Rollback diff --git a/docs/dashboard/node-flows.md b/docs/dashboard/node-flows.md index 66ad383700..f77885f78b 100644 --- a/docs/dashboard/node-flows.md +++ b/docs/dashboard/node-flows.md @@ -16,6 +16,8 @@ Credential slots use the versioned definition's allowed kinds and required capab Selecting, replacing, or removing a credential updates only that slot in the node's canonical `credentialBindings` and immediately saves the complete draft through the current optimistic revision. The dashboard then adopts the canonical flow revision and refreshes governed review. Saving, saved, policy-denial, and error states are announced. A revision conflict loads the latest draft, preserves the selected slot workflow and sibling edits, and requires the operator to choose again rather than replaying the stale mutation. Credential plaintext remains behind the broker and is excluded from graph data, component state, browser output, logs, and documentation examples. +Removing a required binding is allowed as a draft edit but immediately changes review and publication readiness to blocked; removing an optional binding remains valid. Publication is denied for required missing bindings and for credentials that become unavailable, unconfigured, revoked, inaccessible to the project, wrong-kind, or short of a required capability. Runtime repeats compatibility against the immutable publication, so a later custody outage, restriction, revocation, or rebinding denies execution instead of using a stale dashboard decision. + The complete governed built-in set currently registered with executable handlers is `input`, `set_fields`, `template`, `provider_prompt`, `http_request`, `condition`, `switch`, `foreach`, `merge`, `delay`, `approval`, `email_draft`, `email_send`, `execute_subflow`, `webhook_trigger`, and `output`. Registered custom definitions can execute only when their validated versioned manifest, immutable artifact, and custom-node runtime are available. Raw legacy `trigger`/`agent`/`task` kinds are translated by the browser import bridge rather than executed directly. Unknown or unregistered types, mockup entries, and definitions marked non-executable are planned or unavailable definitions. diff --git a/docs/index.md b/docs/index.md index f482d30009..955f071a30 100644 --- a/docs/index.md +++ b/docs/index.md @@ -215,6 +215,7 @@ Use this page as the main entrypoint. - [Chat Provider Integrations](./settings/chat-provider-integrations.md) - [Settings Reference](./settings/index.md) - [Google Drive Project Mount](./settings/google-drive-mount.md) + - [Integrations](./settings/integrations.md) - [Qwen Code Integration](./settings/qwen-code-integration.md) - [OpenCode Integration](./settings/opencode-integration.md) - [Operations Runbook](./operations/runbook.md) diff --git a/docs/operations/credential-security.md b/docs/operations/credential-security.md index 90f2a4eaa1..48665eb98f 100644 --- a/docs/operations/credential-security.md +++ b/docs/operations/credential-security.md @@ -45,6 +45,12 @@ For mounted-file custody, `CODE_UX_CREDENTIAL_KEY_FILE` identifies a regular, ow Electron serializes first-use root-key creation, persists only the OS-protected blob through an atomic owner-only file replacement, and refuses credential operations when `safeStorage` is unavailable. Vault and KMS adapters validate 32-byte caller-owned key material and report the active key id/version in health results. No provider silently falls back to plaintext or an insecure locally derived key. +| Deployment boundary | Root-key custody | Provisioning behavior | +| --- | --- | --- | +| Normal CLI dashboard on loopback with local authentication | Owner-only file under the user-home Code UX security directory | Automatically created on first use and reused after restart. A normal local dashboard user does not mount or configure a key file. | +| Electron desktop | Operating-system `safeStorage` | Automatically creates and persists only the OS-protected blob; unavailable `safeStorage` blocks credential operations. | +| Dashboard-disabled headless, server mode, authenticated dashboard, non-loopback binding, or remote credential management | Explicit mounted file, Vault, or KMS provider | Never auto-provisions local custody. Setup and recovery fail closed until the configured provider reports available, secure key identity and version metadata. | + ## Recovery and rotation Back up root keys independently from `app.db`. For the normal local dashboard, back up `~/.code-ux/security/credential-root.key` while preserving owner-only handling; for external providers, retain every referenced key version. Losing a required key version makes its ciphertext unrecoverable by design. Restoring only SQLite is insufficient. @@ -62,3 +68,9 @@ Existing global credentials created before management ownership was stored are m Project-scoped routes live under `/api/projects/:projectId/credentials`. Supported operations are create, bounded-name update (`PATCH /:credentialId`), bind, metadata-only compatibility assessment, test, rotate, replace, revoke, promote, and restrict. Compatibility evaluates key-backend readiness, configuration, active status, project access, allowed kinds, and all required capabilities without resolving plaintext. A backend is ready only when it is available and secure and reports both a non-empty key ID and a key version; missing key identity metadata produces the stable `backend_unavailable` compatibility issue. List, compatibility, health, and mutation responses return metadata or policy results only. Existing dashboard authentication and remote credential-management guards apply before these routes. Runtime validation failures return `400`, project/management denials return `403`, compare-and-swap conflicts return `409`, invalid encrypted state returns `422`, and unavailable key custody returns an actionable `503` response. + +## Troubleshooting without disclosure + +- If custody is unavailable, inspect the metadata-only credential health or readiness result and the configured provider name. For the normal loopback dashboard, verify ownership, file type, and owner-only modes on the existing Code UX security path; for Electron, restore OS `safeStorage`; for headless or remote operation, restore the configured mount, Vault, or KMS version. Never paste, print, regenerate over, or move root-key material into a repository to diagnose the failure. +- If a mutation reports a stale `expectedVersion`, refresh credential metadata and review the newer scope, capabilities, validation state, and status before retrying. Do not reuse the rejected request blindly and do not bypass the comparison check. +- If encrypted rows exist but their key version is unavailable, restore the exact retained provider version before starting runners. Replacing it with a new key does not decrypt old envelopes; restore from the independent custody backup or recover the affected credential through the supported replacement workflow after the runtime is ready. diff --git a/docs/operations/server-mode.md b/docs/operations/server-mode.md index e6a8e19cf5..a46d3b104d 100644 --- a/docs/operations/server-mode.md +++ b/docs/operations/server-mode.md @@ -107,6 +107,8 @@ Do not include `Authorization` headers in probe logs. The probe endpoints do not `/ready` also reports `credentialKey`, `auditStore`, and `distributedRunner`. `/health` remains live during a key-provider outage, while `/ready` returns `503`. Startup aborts before dashboard or MCP binding when encrypted credential rows exist but their key provider cannot recover the wrapping key. Server mode never auto-provisions local-file custody. Select a provider with `CODE_UX_CREDENTIAL_KEY_PROVIDER=mounted-key-file|vault|kms`; mounted files use `CODE_UX_CREDENTIAL_KEY_FILE` and owner-only permissions. Vault/KMS modes require their host adapter to be configured and healthy. +The same explicit-custody requirement applies to dashboard-disabled headless operation, authenticated dashboards, non-loopback dashboard bindings, and remote credential management. Only the trusted loopback local dashboard auto-provisions its owner-only user-home key; Electron uses OS `safeStorage`. Remote setup therefore fails closed rather than borrowing the local-dashboard key, deriving a key, or falling back to plaintext. Restore the configured mount or the exact Vault/KMS key version before enabling runners; do not copy root keys into SQLite, a project checkout, deployment logs, or diagnostic bundles. + Authenticated operators can inspect `/api/admin/readiness`, export redacted NDJSON from `/api/admin/audit/export`, and sample `/api/admin/metrics/slo`. Audit rows include the correlation id, principal, project, action, outcome, and redacted metadata for management requests, credential access, runs, attempts, approvals, and outbox delivery. ## Backup, Restore, Rotation, And Rollback @@ -235,6 +237,8 @@ Existing HTTP sessions authenticated with the previous token should be treated a | Worker appears stale or offline | Heartbeats stopped, the worker process is down, network access failed, or the stable connection key changed unexpectedly. | Restart the worker with the same `--connection-key`, verify `/ready`, and check logs for bounded connection metadata. | | Worker connects but does not claim work | No active project assignment, project not included in `--project-id` / `--active-project-id`, stale endpoint status, task executor mismatch, or no lease returned. | Confirm project assignment and worker status, then verify queued dispatches. Do not start local execution without a lease token. | | `/health` passes but `/ready` fails | Listener is alive but runtime readiness has not completed or the server is degraded. | Wait for startup recovery to finish, then inspect structured logs. Use `/ready` for load balancer readiness gates. | +| `/ready` reports credential custody unavailable | The explicit mounted-file, Vault, or KMS provider is missing, insecure, unhealthy, or cannot return the required key version. | Keep runners disabled, inspect metadata-only readiness and provider configuration, and restore the exact provider/key version. Do not print key material or substitute a new key for encrypted rows. | +| A credential operation returns a version conflict | Another operator changed metadata, scope, capabilities, status, or encrypted value first. | Refresh the metadata-only record, review the new version, and intentionally retry with that version. Do not bypass optimistic concurrency. | | Secret values appear in an exported settings bundle | The export was explicitly approved with `includeSecrets: true`. | Store the bundle only in approved secret storage, rotate exposed credentials if it was shared, and prefer redacted exports for review. | ## Related Docs @@ -242,4 +246,5 @@ Existing HTTP sessions authenticated with the previous token should be treated a - [MCP Runtime and Dispatch](../mcp/runtime-and-dispatch.md) - [Streamable HTTP Worker Gateway](../architecture/streamable-http-worker-gateway.md) - [Security Hardening](./security-hardening.md) +- [Automation Credential Security](./credential-security.md) - [CLI Commands Reference](../reference/cli-commands.md) diff --git a/docs/settings/integrations.md b/docs/settings/integrations.md index c19a83155a..f7d5a2c5a9 100644 --- a/docs/settings/integrations.md +++ b/docs/settings/integrations.md @@ -29,6 +29,15 @@ The create form requires an explicit name, kind, project or global scope, capabi Each project-managed credential supports bounded rename, metadata-only validation test, value rotation, encrypted-state replacement, monotonic access restriction, confirmed promotion, and confirmed revocation. Revocation requires typing `REVOKE` exactly; each lifecycle confirmation starts with cleared confirmation state and returns focus to the credential controls when it closes. Every lifecycle request uses the metadata version shown by the service. If another session wins the compare-and-swap update, the detail view refreshes metadata and asks the operator to review and retry instead of overwriting the newer state. +| Workflow | What the operator supplies | What remains readable afterward | +| --- | --- | --- | +| Create | Name, kind, write-only value, explicit capabilities, and project/global policy | Metadata, configured state, validation state, scope, capabilities, and version only. | +| Update metadata | A bounded display name and current version | Updated metadata; kind and management ownership cannot be changed. | +| Rotate / replace | A new write-only value and current version | New key/version and validation metadata, never either the old or new value. | +| Test | The current version | `valid`, `invalid`, or `unavailable` plus timestamps; no tested value or low-level custody error. | +| Restrict / promote | A monotonic restriction, or a confirmed global allowlist expansion owned by the managing project | Updated non-secret policy metadata. | +| Revoke | Exact confirmation and current version | Revoked status and audit metadata; the stored value cannot be read back. | + Secret inputs are write-only. Create, rotate, and replace fields are never populated from responses, are cleared after successful or failed submissions and project changes, and are removed with the detail view. Notices, metadata cards, browser storage, and reusable drafts contain no secret value. An allowlisted project that is not the management owner sees a **Use only** state and cannot invoke management actions. Unavailable key custody leaves non-secret metadata visible and disables secret-bearing changes and tests. Follow the inline custody guidance, restore secure storage, then use **Refresh**. See [Automation Credential Security](../operations/credential-security.md) for encryption, authority, recovery, and API behavior. @@ -63,12 +72,14 @@ If the saved setting does not appear to take effect: - Check for a project or sprint override that takes precedence over the system value. - Refresh the affected dashboard page if the setting controls a rendered surface. - Restart the local runtime only when the setting explicitly controls startup, listener, or process-level behavior. +- If secure custody is unavailable, keep the metadata view open, restore the deployment's supported custody provider, and use **Refresh**. Local loopback CLI/dashboard mode provisions its owner-only user-home key automatically; do not add mounted-key configuration for a normal local user. +- If a save reports stale metadata, review the refreshed record before retrying with its new version. Never copy secret fields into notes, browser storage, logs, or a repository as a workaround. ## Related Documentation - [Settings overview](./index.md) - [Automation Credential Security](../operations/credential-security.md) - [Google Drive Project Mount](./google-drive-mount.md) -- [Dashboard Settings](../../dashboard/design-system-settings.md) -- [Configuration and Storage](../configuration-and-storage.md) -- [Security Hardening](../../operations/security-hardening.md) +- [Dashboard Settings](../dashboard/design-system-settings.md) +- [Configuration and Storage](./configuration-and-storage.md) +- [Security Hardening](../operations/security-hardening.md) From 06f4cf9f77217f327b3f2126fb86099a204ad409 Mon Sep 17 00:00:00 2001 From: Code UX Date: Mon, 13 Jul 2026 23:50:21 +0000 Subject: [PATCH 17/22] fix(task T09): address qa review via codex --- docs-web/content/docs/user-dashboard-nodes-canvas.mdx | 6 +++++- docs-web/user/dashboard/nodes-canvas.md | 6 +++++- docs/dashboard/nodes-canvas.md | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs-web/content/docs/user-dashboard-nodes-canvas.mdx b/docs-web/content/docs/user-dashboard-nodes-canvas.mdx index 8aad7c5f0f..dc3c198a2b 100644 --- a/docs-web/content/docs/user-dashboard-nodes-canvas.mdx +++ b/docs-web/content/docs/user-dashboard-nodes-canvas.mdx @@ -26,7 +26,11 @@ Validated custom definitions can also execute after their immutable artifact and ## Credentials, review, and publication -Credential slots display metadata-only status such as bound, missing, or denied and can submit a binding request. Secret values remain behind the credential broker and are not returned to the graph or browser. +Credential slots display metadata-only status such as bound, missing, or denied. Opening a slot picker loads project-visible credential metadata and secure-backend health, then assesses every candidate against the versioned definition's allowed kinds and required capabilities. Only active, configured, project-authorized candidates with compatible kind/capabilities and ready secure custody are selectable; incompatible candidates remain non-selectable with a safe policy reason. The picker never resolves credential plaintext. + +Bind, replace, and remove actions persist directly from the inspector. Code UX changes only the selected slot's `{ slot, credentialId }` entry in the node's canonical `credentialBindings`, preserves sibling bindings and node data, and saves the complete graph with the loaded `draftRevision`. Removing a required binding is allowed as a draft edit, but the refreshed review immediately marks that requirement missing and blocks publication until it is satisfied. + +After a successful mutation, the page refetches the canonical flow, adopts its new revision, and refreshes governed review before reporting success. If another editor advanced the draft, the optimistic conflict path loads the latest flow and review, keeps the selected node/slot workflow available, and requires the operator to choose again; it never replays the stale binding over newer edits. Authorization or compatibility denial leaves the prior binding state intact or reports the saved binding as currently denied. Graphs, requests, component state, notices, browser storage, logs, and rendered review contain credential IDs and non-secret metadata only—never stored values. Agent attachments are also metadata-only. The selected project supplies the available preset names, and the selected flow supplies its current skill names and descriptions. Attach and detach refresh that governed backend state; project or flow transitions clear previous bindings and ignore obsolete requests. Agent instructions, custom source, credentials, and decrypted values are never rendered by the attachment controls. diff --git a/docs-web/user/dashboard/nodes-canvas.md b/docs-web/user/dashboard/nodes-canvas.md index 5d213ce7b9..26f837a8c2 100644 --- a/docs-web/user/dashboard/nodes-canvas.md +++ b/docs-web/user/dashboard/nodes-canvas.md @@ -26,7 +26,11 @@ Validated custom definitions can also execute after their immutable artifact and ## Credentials, review, and publication -Credential slots display metadata-only status such as bound, missing, or denied and can submit a binding request. Secret values remain behind the credential broker and are not returned to the graph or browser. +Credential slots display metadata-only status such as bound, missing, or denied. Opening a slot picker loads project-visible credential metadata and secure-backend health, then assesses every candidate against the versioned definition's allowed kinds and required capabilities. Only active, configured, project-authorized candidates with compatible kind/capabilities and ready secure custody are selectable; incompatible candidates remain non-selectable with a safe policy reason. The picker never resolves credential plaintext. + +Bind, replace, and remove actions persist directly from the inspector. Code UX changes only the selected slot's `{ slot, credentialId }` entry in the node's canonical `credentialBindings`, preserves sibling bindings and node data, and saves the complete graph with the loaded `draftRevision`. Removing a required binding is allowed as a draft edit, but the refreshed review immediately marks that requirement missing and blocks publication until it is satisfied. + +After a successful mutation, the page refetches the canonical flow, adopts its new revision, and refreshes governed review before reporting success. If another editor advanced the draft, the optimistic conflict path loads the latest flow and review, keeps the selected node/slot workflow available, and requires the operator to choose again; it never replays the stale binding over newer edits. Authorization or compatibility denial leaves the prior binding state intact or reports the saved binding as currently denied. Graphs, requests, component state, notices, browser storage, logs, and rendered review contain credential IDs and non-secret metadata only—never stored values. Agent attachments are also metadata-only. The selected project supplies the available preset names, and the selected flow supplies its current skill names and descriptions. Attach and detach refresh that governed backend state; project or flow transitions clear previous bindings and ignore obsolete requests. Agent instructions, custom source, credentials, and decrypted values are never rendered by the attachment controls. diff --git a/docs/dashboard/nodes-canvas.md b/docs/dashboard/nodes-canvas.md index 1181d90371..a30cc8372a 100644 --- a/docs/dashboard/nodes-canvas.md +++ b/docs/dashboard/nodes-canvas.md @@ -12,7 +12,11 @@ On the first load for a selected project, the dashboard checks the former `codeu The versioned definition registry supplies the palette, executable state, typed ports, configuration and widget schemas, capabilities, credential slots, side-effect classification, and default retry/timeout policy. The inspector is rendered from the selected definition rather than a hard-coded node form. The graph stores a type/version reference, non-secret configuration, and credential ids; it never stores custom source or credential values. -Credential slots show metadata-only states such as bound, missing, or denied and can submit a binding request. Secret material stays behind the credential broker and is excluded from graphs, browser output, logs, and examples. +Credential slots show metadata-only states such as bound, missing, or denied. Opening a slot picker loads project-visible credential metadata and secure-backend health, then assesses every candidate against the versioned definition's allowed kinds and required capabilities. Only active, configured, project-authorized candidates with compatible kind/capabilities and ready secure custody are selectable; incompatible candidates remain non-selectable with a safe policy reason. The picker never resolves credential plaintext. + +Bind, replace, and remove actions persist directly from the inspector. Code UX changes only the selected slot's `{ slot, credentialId }` entry in the node's canonical `credentialBindings`, preserves sibling bindings and node data, and saves the complete graph with the loaded `draftRevision`. Removing a required binding is allowed as a draft edit, but the refreshed review immediately marks that requirement missing and blocks publication until it is satisfied. + +After a successful mutation, the page refetches the canonical flow, adopts its new revision, and refreshes governed review before reporting success. If another editor advanced the draft, the optimistic conflict path loads the latest flow and review, keeps the selected node/slot workflow available, and requires the operator to choose again; it never replays the stale binding over newer edits. Authorization or compatibility denial leaves the prior binding state intact or reports the saved binding as currently denied. Graphs, requests, component state, notices, browser storage, logs, and rendered review contain credential IDs and non-secret metadata only—never stored values. Pointer dragging uses local preview state inside the canvas and persists the final position only on pointer release. The workspace also suspends the global animated WebGL background while `/nodes` is active, which keeps canvas interaction on a bounded compositor path without changing the configured appearance on other visible routes. From 67f91317cdda8563bdcb7680771a0ebbabf69f63 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 14 Jul 2026 00:02:28 +0000 Subject: [PATCH 18/22] feat(task T10): implement via codex --- .../CustomDashboardCredentialSlotsPanel.tsx | 8 +- .../__tests__/CustomDashboardsPage.test.tsx | 2 +- .../docs/operations-credential-security.mdx | 8 + docs-web/operations/credential-security.md | 8 + docs/operations/credential-security.md | 8 + playwright.config.ts | 9 +- tests/backend/ci/workflow-health.test.ts | 6 +- .../automation-credential-routes.test.ts | 61 +++- .../credentialed-automation-e2e.test.ts | 263 +++++++++++++++++- tests/e2e/helpers/e2e-api.ts | 70 +++++ ...stom-dashboard-credential-bindings.spec.ts | 194 +++++++++++++ .../node-credential-bindings.spec.ts | 112 ++++++++ .../settings/automation-credentials.spec.ts | 89 ++++++ 13 files changed, 831 insertions(+), 7 deletions(-) create mode 100644 tests/e2e/navigation/custom-dashboard-credential-bindings.spec.ts create mode 100644 tests/e2e/navigation/node-credential-bindings.spec.ts create mode 100644 tests/e2e/settings/automation-credentials.spec.ts diff --git a/dashboard/src/v2/components/custom-dashboards/CustomDashboardCredentialSlotsPanel.tsx b/dashboard/src/v2/components/custom-dashboards/CustomDashboardCredentialSlotsPanel.tsx index 02342615b9..15ee2f707a 100644 --- a/dashboard/src/v2/components/custom-dashboards/CustomDashboardCredentialSlotsPanel.tsx +++ b/dashboard/src/v2/components/custom-dashboards/CustomDashboardCredentialSlotsPanel.tsx @@ -85,6 +85,7 @@ export const CustomDashboardCredentialSlotsPanel: FunctionComponent { const [selectedCredentialBySlot, setSelectedCredentialBySlot] = useState>({}); const actionRefs = useRef>({}); + const selectRefs = useRef>({}); useEffect(() => { setSelectedCredentialBySlot({}); @@ -99,7 +100,11 @@ export const CustomDashboardCredentialSlotsPanel: FunctionComponent { - window.requestAnimationFrame(() => actionRefs.current[slotId]?.focus({ preventScroll: true })); + window.requestAnimationFrame(() => { + const action = actionRefs.current[slotId]; + const target = action && !action.disabled ? action : selectRefs.current[slotId]; + target?.focus({ preventScroll: true }); + }); }; const bind = async (slotId: string): Promise => { @@ -242,6 +247,7 @@ export const CustomDashboardCredentialSlotsPanel: FunctionComponent Compatible credential