From 6527cf2a3ae18f9c33fe6b5ec994631d921cc208 Mon Sep 17 00:00:00 2001 From: RedStar Date: Mon, 19 Oct 2026 12:04:15 +0200 Subject: [PATCH 1/8] feat(config): parse strict deployment settings from config.yml Add the deployment settings document (execution limits, model, container runner) with one strict parser shared by the file and the dashboard: an unknown key, an out-of-range value, a credential-shaped string or bad YAML refuses the whole document. Read the edition and the settings source from CODE_ZERO_EDITION and CODE_ZERO_SETTINGS_SOURCE, never from the host, and refuse the dashboard source on the cloud edition. The instance file is now config.yml by default; an explicit CODE_ZERO_CONFIG still wins, and code-zero.deployment.yml is read when it is the only file present. The timeout is named for what it limits: one repository command (command_timeout_minutes), not a whole run. --- .dockerignore | 2 + .gitignore | 4 +- code-zero.deployment.example.yml | 35 -- config.example.yml | 74 +++ packages/core/config/src/deployment.test.ts | 127 ++++ packages/core/config/src/deployment.ts | 149 ++++- packages/core/config/src/index.ts | 31 + packages/core/config/src/settings.test.ts | 410 ++++++++++++ packages/core/config/src/settings.ts | 653 ++++++++++++++++++++ 9 files changed, 1434 insertions(+), 51 deletions(-) delete mode 100644 code-zero.deployment.example.yml create mode 100644 config.example.yml create mode 100644 packages/core/config/src/settings.test.ts create mode 100644 packages/core/config/src/settings.ts diff --git a/.dockerignore b/.dockerignore index 8644d67..66cd578 100644 --- a/.dockerignore +++ b/.dockerignore @@ -25,6 +25,8 @@ **/.env **/.env.* !**/.env.example +config.yml +apps/web/config.yml code-zero.deployment.yml Dockerfile .dockerignore diff --git a/.gitignore b/.gitignore index 2122c2d..e65ba40 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,7 @@ test-report.junit.xml # Local editor tooling; never something a checkout should carry. /code /vscode_cli.tar.gz +# The instance file (config.yml) where a deployment keeps it locally, and its old name anywhere. +/config.yml +/apps/web/config.yml code-zero.deployment.yml -!code-zero.deployment.example.yml diff --git a/code-zero.deployment.example.yml b/code-zero.deployment.example.yml deleted file mode 100644 index 1a4d566..0000000 --- a/code-zero.deployment.example.yml +++ /dev/null @@ -1,35 +0,0 @@ -# Deployment policy for the dashboard, copied to `code-zero.deployment.yml` (or to any path named -# by CODE_ZERO_CONFIG). Every section is optional; a deployment that has changed nothing ships no -# file at all and gets these defaults. -# -# What belongs here, and what does not: -# -# - Here: policy. Values that are fixed for the life of the process, are not secret, and are read -# more easily as a structured document than as a comma-separated variable. -# - The environment: secrets, and the two bootstrap values needed to find everything else -# (DATABASE_URL, CODE_ZERO_CONFIG). -# - The `repository` table: anything that changes while the process runs. Which checkouts a task -# may target, and which repositories the poller watches, are rows an operator edits from the -# dashboard — a repository added a moment ago is answerable without a restart. -# -# Every field is validated. A value that is refused is reported on stderr with its own `$.path` and -# falls back to the default below, so one wrong line does not stop the dashboard from loading. - -control_plane: - # Origins allowed to read /api/v1/** cross-origin via CORS. Empty by default: `tasks.list`, - # `tasks.get` and `health` are unauthenticated by design, so letting a browser read their - # responses from another origin is an explicit opt-in, not the default. - origins: [] - # - https://ops.example.com - - # Execution modes each operator token may request from `tasks.create`, keyed by the principal - # name in CODE_ZERO_CONTROL_PLANE_TOKENS. Without a grant a principal may only request the - # non-writable `observe` and `suggest`. An unknown mode, or an unknown principal name, is - # refused rather than quietly narrowed. - modes: {} - # ci: [observe, suggest] - # release: [observe, suggest, fix, autonomous] - -poll: - # Seconds between polling passes. Clamped to 15..3600. - interval_seconds: 60 diff --git a/config.example.yml b/config.example.yml new file mode 100644 index 0000000..5c229dc --- /dev/null +++ b/config.example.yml @@ -0,0 +1,74 @@ +# Instance configuration for the dashboard, copied to `config.yml` in the server's working +# directory (or to any path named by CODE_ZERO_CONFIG). Every section is optional; a deployment that +# has changed nothing ships no file at all and gets these defaults. The old name, +# `code-zero.deployment.yml`, is still read when it is the only file present. +# +# This is not `.code-zero.yml`: that file lives in each repository and is that repository's own, +# untrusted policy. This one belongs to whoever runs the deployment, and where both speak, the +# stricter wins. +# +# What belongs here, and what does not: +# +# - Here: policy. Values that are fixed for the life of the process, are not secret, and are read +# more easily as a structured document than as a comma-separated variable. +# - The environment: secrets, and the bootstrap values needed to find everything else +# (DATABASE_URL, CODE_ZERO_CONFIG, CODE_ZERO_EDITION, CODE_ZERO_SETTINGS_SOURCE). +# - The `repository` table: anything that changes while the process runs. Which checkouts a task +# may target, and which repositories the poller watches, are rows an operator edits from the +# dashboard — a repository added a moment ago is answerable without a restart. +# +# The control_plane and poll sections are validated field by field: a value that is refused is +# reported on stderr with its own `$.path` and falls back to the default below, so one wrong line +# does not stop the dashboard from loading. The settings sections at the end are stricter. + +control_plane: + # Origins allowed to read /api/v1/** cross-origin via CORS. Empty by default: `tasks.list`, + # `tasks.get` and `health` are unauthenticated by design, so letting a browser read their + # responses from another origin is an explicit opt-in, not the default. + origins: [] + # - https://ops.example.com + + # Execution modes each operator token may request from `tasks.create`, keyed by the principal + # name in CODE_ZERO_CONTROL_PLANE_TOKENS. Without a grant a principal may only request the + # non-writable `observe` and `suggest`. An unknown mode, or an unknown principal name, is + # refused rather than quietly narrowed. + modes: {} + # ci: [observe, suggest] + # release: [observe, suggest, fix, autonomous] + +poll: + # Seconds between polling passes. Clamped to 15..3600. + interval_seconds: 60 + +# Deployment settings: the limits every run is held to, shown at /settings in the dashboard. +# +# These three sections are read only while CODE_ZERO_SETTINGS_SOURCE is `file` (the default). With +# `dashboard` the settings live in Postgres instead, and settings left here are ignored with a +# warning rather than merged. They are strict: an unknown key, an out-of-range value, or anything +# shaped like a credential makes the whole settings document invalid, and no task starts until it is +# fixed. Uncomment to set them; the values shown are the defaults, except where noted. +# +# execution: +# # Tasks this deployment runs at once; the rest wait in the queue. 1..64. +# max_concurrent_tasks: 4 +# # Minutes before ONE repository command is stopped (and the longest a hosted sandbox lease +# # lasts). Not a limit on a whole run, which can execute several commands. 1..1440. +# command_timeout_minutes: 30 +# # Modes a run may start in. Must include observe, the mode that never writes. +# allowed_modes: [observe, suggest, fix, autonomous] +# # The widest network any run may have: none, restricted, or full. A repository may only narrow it. +# max_network: full +# +# # Not set by default: each repository's .code-zero.yml chooses. When set, replaces that choice for +# # every run. The provider's credential stays in the environment (ANTHROPIC_API_KEY, ...). +# model: +# provider: anthropic +# name: claude-sonnet-4-5 +# +# # Not set by default: each repository chooses. When set, forces every run this process executes +# # into a container with this engine (docker or podman) and image. Only container isolation can be +# # imposed from here. +# runner: +# isolation: container +# engine: docker +# image: ghcr.io/acme/code-zero-runner:1.0 diff --git a/packages/core/config/src/deployment.test.ts b/packages/core/config/src/deployment.test.ts index 50b723a..0ec1127 100644 --- a/packages/core/config/src/deployment.test.ts +++ b/packages/core/config/src/deployment.test.ts @@ -1,10 +1,17 @@ +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { describe, expect, it } from 'vitest'; import { defaultDeploymentConfig, describeDeploymentConfigIssues, + loadDeploymentFile, + locateDeploymentConfig, parseDeploymentConfig, + parseDeploymentFile, } from './deployment.js'; +import { defaultDeploymentSettings } from './settings.js'; describe('parseDeploymentConfig', () => { it('reads the whole file', () => { @@ -130,3 +137,123 @@ describe('describeDeploymentConfigIssues', () => { ); }); }); + +describe('parseDeploymentFile', () => { + it('reads the deployment configuration and the settings from one file', () => { + const file = parseDeploymentFile(` +poll: + interval_seconds: 30 +execution: + max_concurrent_tasks: 2 +`); + + expect(file.config.poll.intervalSeconds).toBe(30); + expect(file.declaresSettings).toBe(true); + expect(file.settings).toEqual({ + ok: true, + value: { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, maxConcurrentTasks: 2 }, + }, + }); + }); + + it('refuses the settings of a file it cannot parse instead of running on the defaults', () => { + expect(parseDeploymentFile('execution: [unclosed\n').settings.ok).toBe(false); + }); + + it('keeps a mistake in the settings from disturbing the control-plane policy', () => { + const file = parseDeploymentFile(` +control_plane: + origins: [https://ops.example.com] +execution: + command_timeout_minutes: 0 +`); + + expect(file.config.controlPlane.origins).toEqual(['https://ops.example.com']); + expect(file.issues).toEqual([]); + expect(file.settings).toEqual({ + ok: false, + issues: [ + { + path: '$.execution.command_timeout_minutes', + message: 'Expected an integer from 1 to 1440.', + }, + ], + }); + }); + + it('says when a file states no settings, so a dashboard source has nothing to ignore', () => { + expect(parseDeploymentFile('poll:\n interval_seconds: 30\n').declaresSettings).toBe(false); + }); +}); + +describe('loadDeploymentFile', () => { + const missing = join(tmpdir(), 'code-zero-missing-deployment-file', 'nothing.yml'); + + it('gives a deployment without a file the default settings', async () => { + const file = await loadDeploymentFile(missing); + + expect(file.settings).toEqual({ ok: true, value: defaultDeploymentSettings }); + }); + + it('refuses a cloud deployment whose service file is missing', async () => { + const file = await loadDeploymentFile(missing, 'cloud'); + + expect(file.settings.ok).toBe(false); + }); +}); + +describe('locateDeploymentConfig', () => { + const directory = '/srv/code-zero'; + const present = + (...names: string[]) => + (path: string): Promise => + Promise.resolve(names.some((name) => path === join(directory, name))); + + it('reads config.yml by default', async () => { + await expect(locateDeploymentConfig({}, directory, present('config.yml'))).resolves.toEqual({ + ok: true, + value: { path: join(directory, 'config.yml'), legacy: false }, + }); + }); + + it('still reads a deployment that kept the old file name, and says so', async () => { + await expect( + locateDeploymentConfig({}, directory, present('code-zero.deployment.yml')), + ).resolves.toEqual({ + ok: true, + value: { path: join(directory, 'code-zero.deployment.yml'), legacy: true }, + }); + }); + + it('refuses to choose between two files rather than reading either', async () => { + const located = await locateDeploymentConfig( + {}, + directory, + present('config.yml', 'code-zero.deployment.yml'), + ); + + expect(located.ok).toBe(false); + }); + + it('reads exactly the file CODE_ZERO_CONFIG names, whatever else is there', async () => { + await expect( + locateDeploymentConfig( + { CODE_ZERO_CONFIG: 'code-zero.deployment.yml' }, + directory, + present('config.yml', 'code-zero.deployment.yml'), + ), + ).resolves.toEqual({ + ok: true, + value: { path: join(directory, 'code-zero.deployment.yml'), legacy: false }, + }); + }); + + it('points a deployment with no file at config.yml, which then reads as the defaults', async () => { + await expect(locateDeploymentConfig({}, directory, present())).resolves.toEqual({ + ok: true, + value: { path: join(directory, 'config.yml'), legacy: false }, + }); + }); +}); diff --git a/packages/core/config/src/deployment.ts b/packages/core/config/src/deployment.ts index 76fa9e0..fc0b521 100644 --- a/packages/core/config/src/deployment.ts +++ b/packages/core/config/src/deployment.ts @@ -1,8 +1,17 @@ -import { readFile } from 'node:fs/promises'; +import { access, readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; import type { RunMode } from '@code-zero/shared'; import { parse } from 'yaml'; +import { + declaresDeploymentSettings, + parseDeploymentSettings, + type DeploymentSettings, + type Edition, + type Parsed, +} from './settings.js'; + /** * One thing wrong with a deployment configuration file, and where. * @@ -65,13 +74,68 @@ const MAXIMUM_POLL_SECONDS = 3_600; const RUN_MODES = new Set(['observe', 'suggest', 'fix', 'autonomous']); /** - * The default path, relative to the process's working directory. + * The instance configuration file, read from the process's working directory by default. + * + * Deliberately not `.code-zero.yml`: that file comes out of whatever checkout a run works on and is + * untrusted input, while this one is the deployment's own trusted policy. Confusing the two would + * mean reading a target repository's file as this deployment's policy, so the names differ in both + * the leading dot and the word. + */ +export const DEPLOYMENT_CONFIG_FILE = 'config.yml'; + +/** + * The name this file had before `config.yml`, still read when it is the only one present so an + * upgrade does not silently drop a deployment's policy. Reported, so the operator renames it. + */ +export const LEGACY_DEPLOYMENT_CONFIG_FILE = 'code-zero.deployment.yml'; + +/** Where the instance configuration lives, and whether it was found under its old name. */ +export interface DeploymentConfigLocation { + path: string; + legacy: boolean; +} + +/** + * Decide which one file is the instance configuration. * - * Named for the deployment rather than the product so it cannot be mistaken for `.code-zero.yml` - * at a glance: the leading dot and the different word are both load-bearing, because confusing the - * two would mean reading a target repository's file as this deployment's own policy. + * `CODE_ZERO_CONFIG` names it outright, and nothing else is looked at. Without it the working + * directory's `config.yml` is the file, or `code-zero.deployment.yml` when only the old name exists. + * Both present is refused rather than resolved: reading one would silently ignore the other, and + * two active files is exactly the ambiguity a single source exists to rule out. */ -export const DEPLOYMENT_CONFIG_FILE = 'code-zero.deployment.yml'; +export async function locateDeploymentConfig( + environment: Readonly>, + directory: string, + exists: (path: string) => Promise = fileExists, +): Promise> { + const named = environment.CODE_ZERO_CONFIG?.trim(); + if (named) return { ok: true, value: { path: resolve(directory, named), legacy: false } }; + const current = join(directory, DEPLOYMENT_CONFIG_FILE); + const legacy = join(directory, LEGACY_DEPLOYMENT_CONFIG_FILE); + const [hasCurrent, hasLegacy] = await Promise.all([exists(current), exists(legacy)]); + if (hasCurrent && hasLegacy) + return { + ok: false, + issues: [ + { + path: '$', + message: `Both ${DEPLOYMENT_CONFIG_FILE} and ${LEGACY_DEPLOYMENT_CONFIG_FILE} exist in ${directory}; keep one, or name it with CODE_ZERO_CONFIG.`, + }, + ], + }; + if (hasLegacy) return { ok: true, value: { path: legacy, legacy: true } }; + return { ok: true, value: { path: current, legacy: false } }; +} + +async function fileExists(path: string): Promise { + try { + await access(path); + return true; + } catch { + // Absent or unreadable both mean "not this candidate"; reading it later reports the reason. + return false; + } +} export interface DeploymentConfigResult { config: DeploymentConfig; @@ -88,34 +152,89 @@ export interface DeploymentConfigResult { * a decision only a composition root can make. */ export async function loadDeploymentConfig(path: string): Promise { + const { config, issues } = await loadDeploymentFile(path); + return { config, issues }; +} + +/** + * Everything the deployment file says: the configuration, and the settings it states. + * + * The two halves fail differently on purpose. A refused configuration field falls back to its own + * default, which is always the most closed one. The settings are refused whole: a limit, a model, + * or a sandbox that was mistyped must not quietly become a different one, so their caller gets the + * complete document or every issue in it, and has to decide what a run does without them. + */ +export interface DeploymentFile extends DeploymentConfigResult { + settings: Parsed; + /** + * Whether the file states any settings section, so a deployment whose settings come from the + * dashboard can say the file's are ignored rather than leave its operator editing a dead copy. + */ + declaresSettings: boolean; +} + +/** Read the deployment file once, for both its configuration and its settings. */ +export async function loadDeploymentFile( + path: string, + edition: Edition = 'self-hosted', +): Promise { let raw: string; try { raw = await readFile(path, 'utf8'); } catch (error) { if (isRecord(error) && error.code === 'ENOENT') - return { config: structuredClone(defaultDeploymentConfig), issues: [] }; + return { + config: structuredClone(defaultDeploymentConfig), + issues: [], + settings: parseDeploymentSettings({}, edition), + declaresSettings: false, + }; + const issue = { path: '$', message: `Could not read ${path}: ${String(error)}` }; return { config: structuredClone(defaultDeploymentConfig), - issues: [{ path: '$', message: `Could not read ${path}: ${String(error)}` }], + issues: [issue], + settings: { ok: false, issues: [issue] }, + declaresSettings: false, }; } - return parseDeploymentConfig(raw); + return parseDeploymentFile(raw, edition); } -/** Validate the file's text. Separated from reading it so the tests never touch a filesystem. */ -export function parseDeploymentConfig(text: string): DeploymentConfigResult { - const issues: DeploymentConfigIssue[] = []; +/** Validate the file's text, for both halves. Separate from reading so tests need no filesystem. */ +export function parseDeploymentFile( + text: string, + edition: Edition = 'self-hosted', +): DeploymentFile { let parsed: unknown; try { parsed = parse(text); } catch (error) { + const issue = { + path: '$', + message: error instanceof Error ? error.message : 'YAML parsing failed.', + }; return { config: structuredClone(defaultDeploymentConfig), - issues: [ - { path: '$', message: error instanceof Error ? error.message : 'YAML parsing failed.' }, - ], + issues: [issue], + settings: { ok: false, issues: [issue] }, + declaresSettings: false, }; } + return { + ...configFromDocument(parsed), + settings: parseDeploymentSettings(parsed, edition), + declaresSettings: declaresDeploymentSettings(parsed), + }; +} + +/** Validate the file's text. Separated from reading it so the tests never touch a filesystem. */ +export function parseDeploymentConfig(text: string): DeploymentConfigResult { + const { config, issues } = parseDeploymentFile(text); + return { config, issues }; +} + +function configFromDocument(parsed: unknown): DeploymentConfigResult { + const issues: DeploymentConfigIssue[] = []; // An empty file is a deployment that wrote the file and configured nothing yet, which is the // same thing as having no file at all. if (parsed === null || parsed === undefined) diff --git a/packages/core/config/src/index.ts b/packages/core/config/src/index.ts index 15300f8..7258e0f 100644 --- a/packages/core/config/src/index.ts +++ b/packages/core/config/src/index.ts @@ -10,12 +10,43 @@ export { defaultDeploymentConfig, DEPLOYMENT_CONFIG_FILE, describeDeploymentConfigIssues, + LEGACY_DEPLOYMENT_CONFIG_FILE, loadDeploymentConfig, + loadDeploymentFile, + locateDeploymentConfig, parseDeploymentConfig, + parseDeploymentFile, type DeploymentConfig, type DeploymentConfigIssue, + type DeploymentConfigLocation, type DeploymentConfigResult, + type DeploymentFile, } from './deployment.js'; +export { + applyDeploymentSettings, + defaultDeploymentSettings, + deploymentSettingsDocument, + editionFromEnvironment, + effectiveExecution, + EXECUTION_LIMITS, + isModeAllowed, + noOrganizationLimits, + organizationLimitsAbovePlan, + organizationLimitsDocument, + parseDeploymentSettings, + parseDeploymentSettingsText, + parseOrganizationLimits, + renderDeploymentSettings, + settingsSourceFromEnvironment, + type DeploymentSettings, + type Edition, + type ExecutionSettings, + type ModelSettings, + type OrganizationLimits, + type Parsed, + type SandboxSettings, + type SettingsSource, +} from './settings.js'; export { assertExecutableCommand, checkKinds, diff --git a/packages/core/config/src/settings.test.ts b/packages/core/config/src/settings.test.ts new file mode 100644 index 0000000..e6b09a5 --- /dev/null +++ b/packages/core/config/src/settings.test.ts @@ -0,0 +1,410 @@ +import { describe, expect, it } from 'vitest'; + +import { defaultConfig, type CodeZeroConfig } from './index.js'; +import { + applyDeploymentSettings, + defaultDeploymentSettings, + editionFromEnvironment, + effectiveExecution, + organizationLimitsAbovePlan, + parseDeploymentSettings, + parseDeploymentSettingsText, + parseOrganizationLimits, + renderDeploymentSettings, + settingsSourceFromEnvironment, + type DeploymentSettings, + type ExecutionSettings, + type SandboxSettings, +} from './settings.js'; + +const SANDBOX: SandboxSettings = { + isolation: 'container', + engine: 'podman', + image: 'ghcr.io/acme/code-zero-runner:1', +}; + +const CONFIGURED: DeploymentSettings = { + execution: { + maxConcurrentTasks: 8, + commandTimeoutMinutes: 20, + allowedModes: ['observe', 'suggest'], + maxNetwork: 'none', + }, + model: { provider: 'anthropic', name: 'claude-sonnet-4-5' }, + runner: SANDBOX, +}; + +const CONFIGURED_YAML = ` +execution: + max_concurrent_tasks: 8 + command_timeout_minutes: 20 + allowed_modes: [suggest, observe] + max_network: none +model: + provider: anthropic + name: claude-sonnet-4-5 +runner: + isolation: container + engine: podman + image: ghcr.io/acme/code-zero-runner:1 +`; + +const PLAN: ExecutionSettings = { + maxConcurrentTasks: 10, + commandTimeoutMinutes: 60, + allowedModes: ['observe', 'suggest', 'fix'], + maxNetwork: 'restricted', +}; + +function repository(overrides: Partial = {}): CodeZeroConfig { + return { ...structuredClone(defaultConfig), ...overrides }; +} + +describe('parseDeploymentSettingsText', () => { + it('reads every section into one document', () => { + expect(parseDeploymentSettingsText(CONFIGURED_YAML)).toEqual({ ok: true, value: CONFIGURED }); + }); + + it.each(['', '# nothing configured yet\n', 'control_plane:\n origins: []\n'])( + 'gives %j the defaults, because it states no setting', + (text) => { + expect(parseDeploymentSettingsText(text)).toEqual({ + ok: true, + value: defaultDeploymentSettings, + }); + }, + ); + + it('refuses unparseable YAML instead of falling back to the defaults', () => { + const parsed = parseDeploymentSettingsText('execution: [unclosed\n'); + + expect(parsed.ok).toBe(false); + expect(!parsed.ok ? parsed.issues[0]?.path : undefined).toBe('$'); + }); + + it('refuses the whole document when one value is wrong, naming every mistake', () => { + expect( + parseDeploymentSettingsText(` +execution: + max_concurrent_tasks: 0 + command_timeout_minutes: 99999 +`), + ).toEqual({ + ok: false, + issues: [ + { + path: '$.execution.max_concurrent_tasks', + message: 'Expected an integer from 1 to 64.', + }, + { + path: '$.execution.command_timeout_minutes', + message: 'Expected an integer from 1 to 1440.', + }, + ], + }); + }); + + it('refuses a misspelled setting rather than ignoring it', () => { + expect(parseDeploymentSettingsText('execution:\n max_concurent_tasks: 2\n')).toEqual({ + ok: false, + issues: [{ path: '$.execution.max_concurent_tasks', message: 'Unknown setting.' }], + }); + }); + + it('keeps observe allowed, since it is the mode every unrequested run starts in', () => { + expect(parseDeploymentSettingsText('execution:\n allowed_modes: [suggest]\n')).toEqual({ + ok: false, + issues: [ + { + path: '$.execution.allowed_modes', + message: 'Expected a list of execution modes that includes observe.', + }, + ], + }); + }); + + it('refuses a runner that would let repositories run on the host', () => { + expect(parseDeploymentSettingsText('runner:\n isolation: local\n image: node:24\n')).toEqual({ + ok: false, + issues: [ + { + path: '$.runner.isolation', + message: + 'Only container isolation can be enforced; remove the runner section to let each repository choose.', + }, + ], + }); + }); + + it('refuses an image the container engine would read as one of its own flags', () => { + const parsed = parseDeploymentSettingsText( + 'runner:\n isolation: container\n image: --privileged\n', + ); + + expect(parsed).toEqual({ + ok: false, + issues: [{ path: '$.runner.image', message: 'Expected a container image reference.' }], + }); + }); + + it('refuses a credential pasted into a setting, so it is never stored or exported', () => { + const parsed = parseDeploymentSettingsText( + 'model:\n provider: openai\n name: sk-live1234567890abcdefghij\n', + ); + + expect(parsed).toEqual({ + ok: false, + issues: [ + { + path: '$.model.name', + message: 'Looks like a credential. Credentials stay in the environment.', + }, + ], + }); + }); + + it('refuses a base URL, which would decide where the model credential is sent', () => { + expect( + parseDeploymentSettingsText( + 'model:\n provider: openai\n name: gpt-5\n base_url: https://example.test\n', + ), + ).toEqual({ + ok: false, + issues: [{ path: '$.model.base_url', message: 'Unknown setting.' }], + }); + }); +}); + +describe('parseDeploymentSettings for a cloud deployment', () => { + it('requires the service to name the model its customers run on', () => { + expect(parseDeploymentSettings({}, 'cloud')).toEqual({ + ok: false, + issues: [{ path: '$.model', message: 'A cloud deployment must configure the model.' }], + }); + }); + + it('does not require a container engine, which a Cloudflare-hosted service does not have', () => { + const parsed = parseDeploymentSettings( + { model: { provider: 'anthropic', name: 'claude-sonnet-4-5' } }, + 'cloud', + ); + + expect(parsed.ok).toBe(true); + }); +}); + +describe('renderDeploymentSettings', () => { + it('exports YAML that reads back as the same settings', () => { + expect(parseDeploymentSettingsText(renderDeploymentSettings(CONFIGURED))).toEqual({ + ok: true, + value: CONFIGURED, + }); + }); + + it('never writes an environment credential into the export', () => { + const secret = 'gateway-key-0123456789'; + const rendered = renderDeploymentSettings( + { ...CONFIGURED, model: { provider: 'ai-gateway', name: `${secret}-model` } }, + { AI_GATEWAY_API_KEY: secret }, + ); + + expect(rendered).not.toContain(secret); + }); +}); + +describe('editionFromEnvironment', () => { + it('is self-hosted unless the deployment says otherwise', () => { + expect(editionFromEnvironment({})).toEqual({ ok: true, value: 'self-hosted' }); + }); + + it('does not take a Cloudflare host for the managed cloud', () => { + expect( + editionFromEnvironment({ CF_PAGES: '1', CLOUDFLARE_ACCOUNT_ID: 'account', CF_WORKER: 'x' }), + ).toEqual({ ok: true, value: 'self-hosted' }); + }); + + it('reads the managed cloud only from its explicit value', () => { + expect(editionFromEnvironment({ CODE_ZERO_EDITION: 'cloud' })).toEqual({ + ok: true, + value: 'cloud', + }); + }); + + it('refuses a value it does not know rather than guessing an edition', () => { + expect(editionFromEnvironment({ CODE_ZERO_EDITION: 'clod' }).ok).toBe(false); + }); +}); + +describe('settingsSourceFromEnvironment', () => { + it('keeps the file as the source of a deployment that never chose one', () => { + expect(settingsSourceFromEnvironment({}, 'self-hosted')).toEqual({ ok: true, value: 'file' }); + }); + + it('makes the dashboard the source only when asked', () => { + expect( + settingsSourceFromEnvironment({ CODE_ZERO_SETTINGS_SOURCE: 'dashboard' }, 'self-hosted'), + ).toEqual({ ok: true, value: 'dashboard' }); + }); + + it('refuses the dashboard as the source of a cloud deployment', () => { + expect( + settingsSourceFromEnvironment({ CODE_ZERO_SETTINGS_SOURCE: 'dashboard' }, 'cloud').ok, + ).toBe(false); + }); + + it('refuses an unknown source rather than picking one', () => { + expect( + settingsSourceFromEnvironment({ CODE_ZERO_SETTINGS_SOURCE: 'database' }, 'self-hosted').ok, + ).toBe(false); + }); +}); + +describe('organization limits', () => { + it('lowers the plan where the organization chose a stricter value', () => { + const limits = parseOrganizationLimits({ + max_concurrent_tasks: 2, + command_timeout_minutes: null, + allowed_modes: ['observe'], + }); + + expect(limits.ok).toBe(true); + if (!limits.ok) return; + expect(effectiveExecution(PLAN, limits.value)).toEqual({ + maxConcurrentTasks: 2, + commandTimeoutMinutes: 60, + allowedModes: ['observe'], + maxNetwork: 'restricted', + }); + }); + + it('names every value that would exceed the plan', () => { + expect( + organizationLimitsAbovePlan( + { + maxConcurrentTasks: 11, + commandTimeoutMinutes: 61, + allowedModes: ['observe', 'autonomous'], + }, + PLAN, + ), + ).toEqual([ + { path: '$.max_concurrent_tasks', message: 'The plan allows at most 10.' }, + { path: '$.command_timeout_minutes', message: 'The plan allows at most 60.' }, + { path: '$.allowed_modes', message: 'The plan does not allow autonomous.' }, + ]); + }); + + it('never lets a stored value exceed a plan that was lowered after it was saved', () => { + expect( + effectiveExecution( + { + maxConcurrentTasks: 3, + commandTimeoutMinutes: 10, + allowedModes: ['observe'], + maxNetwork: 'none', + }, + { maxConcurrentTasks: 8, commandTimeoutMinutes: 45, allowedModes: ['observe', 'suggest'] }, + ), + ).toEqual({ + maxConcurrentTasks: 3, + commandTimeoutMinutes: 10, + allowedModes: ['observe'], + maxNetwork: 'none', + }); + }); +}); + +describe('applyDeploymentSettings', () => { + it('caps the command timeout the repository asked for', () => { + const applied = applyDeploymentSettings( + repository({ agent: { maxAttempts: 3, timeoutMs: 3_600_000, maxChangedFiles: 10 } }), + { ...defaultDeploymentSettings, execution: { ...PLAN, commandTimeoutMinutes: 15 } }, + ); + + expect(applied.agent.timeoutMs).toBe(900_000); + }); + + it('keeps a repository command timeout that is already shorter', () => { + const applied = applyDeploymentSettings( + repository({ agent: { maxAttempts: 3, timeoutMs: 60_000, maxChangedFiles: 10 } }), + defaultDeploymentSettings, + ); + + expect(applied.agent.timeoutMs).toBe(60_000); + }); + + it('replaces the model a repository chose with the deployment model', () => { + const applied = applyDeploymentSettings( + repository({ + model: { + provider: 'openai', + name: 'gpt-5', + inputCostPerMillionTokens: 1, + outputCostPerMillionTokens: 2, + }, + }), + CONFIGURED, + ); + + expect(applied.model).toEqual({ provider: 'anthropic', name: 'claude-sonnet-4-5' }); + }); + + it('forces the deployment sandbox and drops the container network the repository named', () => { + const applied = applyDeploymentSettings( + repository({ + runner: { + isolation: 'local', + engine: 'docker', + image: 'attacker/image', + network: 'host', + workdir: '/workspace', + maxOutputBytes: 200_000, + }, + }), + CONFIGURED, + ); + + expect(applied.runner).toEqual({ + isolation: 'container', + engine: 'podman', + image: 'ghcr.io/acme/code-zero-runner:1', + workdir: '/workspace', + maxOutputBytes: 200_000, + }); + }); + + it('holds every run to the network ceiling, with or without a deployment sandbox', () => { + const applied = applyDeploymentSettings(repository({ permissions: { network: 'full' } }), { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, maxNetwork: 'restricted' }, + }); + + expect(applied.permissions.network).toBe('restricted'); + }); + + it('lets a repository narrow the network below the deployment ceiling', () => { + const applied = applyDeploymentSettings( + repository({ permissions: { network: 'none' } }), + defaultDeploymentSettings, + ); + + expect(applied.permissions.network).toBe('none'); + }); + + it('leaves isolation and model to the repository when the deployment names neither', () => { + const original = repository(); + const applied = applyDeploymentSettings(original, defaultDeploymentSettings); + + expect(applied.runner).toEqual(original.runner); + expect(applied.model).toEqual(original.model); + }); + + it('applies the organization command timeout rather than the plan when it is stricter', () => { + const applied = applyDeploymentSettings(repository(), CONFIGURED, { + ...CONFIGURED.execution, + commandTimeoutMinutes: 5, + }); + + expect(applied.agent.timeoutMs).toBe(300_000); + }); +}); diff --git a/packages/core/config/src/settings.ts b/packages/core/config/src/settings.ts new file mode 100644 index 0000000..f7f5c91 --- /dev/null +++ b/packages/core/config/src/settings.ts @@ -0,0 +1,653 @@ +import { + redactSecrets, + secretValuesFromEnvironment, + type ModelProviderKind, + type NetworkPolicy, + type RunMode, +} from '@code-zero/shared'; +import { parse, stringify } from 'yaml'; + +import type { DeploymentConfigIssue } from './deployment.js'; +import type { CodeZeroConfig, ContainerEngine } from './index.js'; + +/** + * Which product a deployment is. + * + * `cloud` is the managed service, where the service's own operators configure the model, the + * sandbox, and every connection, and customers only see whether each one works. It is stated by the + * deployment, never inferred: a self-hosted install that happens to run on Cloudflare is still + * self-hosted, and treating it otherwise would lock its operator out of their own settings. + */ +export type Edition = 'self-hosted' | 'cloud'; + +/** + * Where a self-hosted deployment's settings are read from. Exactly one of the two is authoritative. + * + * `file` is the trusted deployment file, which the dashboard shows read-only. `dashboard` is the + * store, edited from the settings page and exportable as the same YAML the file would hold. + */ +export type SettingsSource = 'file' | 'dashboard'; + +/** The answer to parsing something untrusted: the value, or every reason it was refused. */ +export type Parsed = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly issues: readonly DeploymentConfigIssue[] }; + +/** How much work the deployment takes on, and in which modes. */ +export interface ExecutionSettings { + /** Tasks this deployment runs at once. The scheduler queues the rest. */ + readonly maxConcurrentTasks: number; + /** + * Minutes after which one repository command is stopped, and the longest a hosted sandbox lease + * lasts. Not a limit on a whole run: a run that executes several commands, or waits on the model, + * can take longer than this in total. + */ + readonly commandTimeoutMinutes: number; + /** Execution modes a run may start in. Always includes `observe`. */ + readonly allowedModes: readonly RunMode[]; + /** + * The widest egress any run may have, however it is executed: the local runner, the container + * sandbox, or a hosted sandbox provider, which receives it with every lease request. A repository + * may narrow it, never widen it. `full` states no ceiling. + */ + readonly maxNetwork: NetworkPolicy; +} + +/** The model every run uses, in place of whatever a repository's `.code-zero.yml` chose. */ +export interface ModelSettings { + readonly provider: ModelProviderKind; + readonly name: string; +} + +/** + * The Docker or Podman sandbox for the runs this process executes itself, in place of whatever a + * repository's `.code-zero.yml` chose. + * + * Only container isolation can be imposed: a deployment setting that downgraded a repository to + * the host would be the one place a run ends up less isolated than its own policy asked for. A hosted + * sandbox provider brings its own image and ignores this one, so the setting only describes runs + * started on this process's own container engine. + */ +export interface SandboxSettings { + readonly isolation: 'container'; + readonly engine: ContainerEngine; + readonly image: string; +} + +/** + * The deployment-wide settings the dashboard shows and, when it is the source, edits. + * + * Every field changes what a run does: nothing here is a label. Credentials never live here — the + * parser refuses anything shaped like one — so the document can be shown and exported whole. + */ +export interface DeploymentSettings { + readonly execution: ExecutionSettings; + /** `null` leaves the model to each repository's `.code-zero.yml`. */ + readonly model: ModelSettings | null; + /** `null` leaves isolation to each repository's `.code-zero.yml`. */ + readonly runner: SandboxSettings | null; +} + +/** + * Stricter limits an organization chose for its own work on the managed cloud. + * + * `null` means the plan's value applies. A stored value is never trusted to stay below the plan, + * because the plan can be lowered after it was saved; {@link effectiveExecution} takes the minimum. + */ +export interface OrganizationLimits { + readonly maxConcurrentTasks: number | null; + readonly commandTimeoutMinutes: number | null; + readonly allowedModes: readonly RunMode[] | null; +} + +const RUN_MODES: readonly RunMode[] = ['observe', 'suggest', 'fix', 'autonomous']; +const NETWORK_POLICIES: readonly NetworkPolicy[] = ['none', 'restricted', 'full']; +const CONTAINER_ENGINES: readonly ContainerEngine[] = ['docker', 'podman']; +const MODEL_PROVIDERS: readonly ModelProviderKind[] = [ + 'ai-gateway', + 'anthropic', + 'claude-code', + 'codex-cli', + 'google', + 'openai', + 'openai-compatible', +]; + +/** The range each limit accepts, published so a form can offer the same bounds the parser checks. */ +export const EXECUTION_LIMITS = { + maxConcurrentTasks: { minimum: 1, maximum: 64 }, + commandTimeoutMinutes: { minimum: 1, maximum: 1_440 }, +} as const; + +/** + * What a deployment gets when it states no setting: today's behaviour, unchanged. + * + * Four concurrent tasks is the scheduler's long-standing default, and thirty minutes is the + * repository policy's own default command timeout, so a deployment that never opens the settings page runs + * exactly as it did before the page existed. + */ +export const defaultDeploymentSettings: DeploymentSettings = { + execution: { + maxConcurrentTasks: 4, + commandTimeoutMinutes: 30, + allowedModes: RUN_MODES, + maxNetwork: 'full', + }, + model: null, + runner: null, +}; + +/** An organization that chose nothing: the plan applies as it is. */ +export const noOrganizationLimits: OrganizationLimits = { + maxConcurrentTasks: null, + commandTimeoutMinutes: null, + allowedModes: null, +}; + +/** The deployment file's top-level keys that belong to the settings document. */ +const SETTINGS_SECTIONS = ['execution', 'model', 'runner'] as const; + +const EXECUTION_KEYS = [ + 'max_concurrent_tasks', + 'command_timeout_minutes', + 'allowed_modes', + 'max_network', +]; +const ORGANIZATION_KEYS = ['max_concurrent_tasks', 'command_timeout_minutes', 'allowed_modes']; +const MODEL_KEYS = ['provider', 'name']; +const RUNNER_KEYS = ['isolation', 'engine', 'image']; + +/** A container image reference: never empty, never a flag, never whitespace. */ +const IMAGE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._/:@-]{0,254}$/; +/** A model name: printable ASCII without whitespace, which every supported transport accepts. */ +const MODEL_NAME = /^[\x21-\x7E]{1,200}$/; + +/** + * Read the edition from the deployment's environment. + * + * Only `CODE_ZERO_EDITION` decides it. A value this function does not know is refused rather than + * read as self-hosted: guessing wrong in either direction either hides a managed service's locks or + * hands a customer the service's own settings. + */ +export function editionFromEnvironment( + environment: Readonly>, +): Parsed { + const value = environment.CODE_ZERO_EDITION?.trim(); + if (!value || value === 'self-hosted') return ok('self-hosted'); + if (value === 'cloud') return ok('cloud'); + return err('CODE_ZERO_EDITION', 'Expected self-hosted or cloud.'); +} + +/** + * Read which source a deployment's settings come from. + * + * The file is the default, so a deployment that upgrades keeps reading the file it already has. The + * dashboard has to be chosen here, in the environment, where only whoever runs the deployment can + * choose it: no request can move the source, so the file's read-only promise cannot be talked + * around through the API. A cloud deployment cannot choose the dashboard at all. + */ +export function settingsSourceFromEnvironment( + environment: Readonly>, + edition: Edition, +): Parsed { + const value = environment.CODE_ZERO_SETTINGS_SOURCE?.trim(); + if (!value || value === 'file') return ok('file'); + if (value !== 'dashboard') return err('CODE_ZERO_SETTINGS_SOURCE', 'Expected file or dashboard.'); + if (edition === 'cloud') + return err( + 'CODE_ZERO_SETTINGS_SOURCE', + 'A cloud deployment reads its service settings from the deployment file only.', + ); + return ok('dashboard'); +} + +/** Whether a parsed deployment file states any settings section at all. */ +export function declaresDeploymentSettings(document: unknown): boolean { + return isRecord(document) && SETTINGS_SECTIONS.some((section) => document[section] !== undefined); +} + +/** + * Parse the settings sections of a deployment document, refusing it whole on any mistake. + * + * Unlike the rest of the deployment file, nothing here falls back to a default field by field: a + * limit or a sandbox that was meant and mistyped must not quietly become a different one, so the + * caller gets either the complete document or every issue in it. Keys outside the three sections + * are ignored, because they belong to the deployment configuration, which parses them itself. + */ +export function parseDeploymentSettings( + document: unknown, + edition: Edition = 'self-hosted', +): Parsed { + // An empty file parses to null, and states no setting, exactly like a file that has none. + const root = document ?? {}; + if (!isRecord(root)) return err('$', 'Expected an object.'); + const issues: DeploymentConfigIssue[] = []; + const settings: DeploymentSettings = { + execution: executionSection(root.execution, issues), + model: modelSection(root.model, issues), + runner: runnerSection(root.runner, issues), + }; + // The managed service names its model here, so no customer repository can pick one that bills the + // service's credentials. Its sandbox is not required here: a hosted sandbox provider is wired by the + // composition root, not described in this file, and a Cloudflare-hosted service has no container + // engine to name. Runs that would reach neither are refused where runs start. + if (edition === 'cloud' && (root.model === undefined || root.model === null)) + issues.push({ path: '$.model', message: 'A cloud deployment must configure the model.' }); + return issues.length > 0 ? { ok: false, issues } : ok(settings); +} + +/** Parse settings written as YAML: an export, or a file that holds nothing else. */ +export function parseDeploymentSettingsText( + text: string, + edition: Edition = 'self-hosted', +): Parsed { + let document: unknown; + try { + document = parse(text); + } catch (error) { + return err('$', error instanceof Error ? error.message : 'YAML parsing failed.'); + } + return parseDeploymentSettings(document, edition); +} + +/** + * The settings as the document they are stored and exported as: the deployment file's own keys. + * + * One shape for the store and the export, so what an administrator downloads is byte for byte what + * the file source would read back. + */ +export function deploymentSettingsDocument(settings: DeploymentSettings): Record { + const document: Record = { + execution: { + max_concurrent_tasks: settings.execution.maxConcurrentTasks, + command_timeout_minutes: settings.execution.commandTimeoutMinutes, + allowed_modes: [...settings.execution.allowedModes], + max_network: settings.execution.maxNetwork, + }, + }; + if (settings.model) + document.model = { provider: settings.model.provider, name: settings.model.name }; + if (settings.runner) + document.runner = { + isolation: settings.runner.isolation, + engine: settings.runner.engine, + image: settings.runner.image, + }; + return document; +} + +/** + * Render the settings as YAML for an administrator to download. + * + * The document holds no credential by construction; every string still passes through redaction + * against the environment's own secrets, so a value that slipped past the parser cannot leave the + * deployment through an export. + */ +export function renderDeploymentSettings( + settings: DeploymentSettings, + environment: Readonly> = process.env, +): string { + return redactSecrets( + stringify(deploymentSettingsDocument(settings)), + secretValuesFromEnvironment(environment), + ); +} + +/** Parse the limits an organization stored. Bounds are checked here; the plan is checked on write. */ +export function parseOrganizationLimits(document: unknown): Parsed { + if (document === null || document === undefined) return ok(noOrganizationLimits); + if (!isRecord(document)) return err('$', 'Expected an object.'); + const issues: DeploymentConfigIssue[] = []; + unknownKeys(document, ORGANIZATION_KEYS, '$', issues); + const limits: OrganizationLimits = { + maxConcurrentTasks: nullable(document.max_concurrent_tasks, () => + boundedInteger( + document.max_concurrent_tasks, + '$.max_concurrent_tasks', + EXECUTION_LIMITS.maxConcurrentTasks, + issues, + ), + ), + commandTimeoutMinutes: nullable(document.command_timeout_minutes, () => + boundedInteger( + document.command_timeout_minutes, + '$.command_timeout_minutes', + EXECUTION_LIMITS.commandTimeoutMinutes, + issues, + ), + ), + allowedModes: nullable(document.allowed_modes, () => + modeList(document.allowed_modes, '$.allowed_modes', issues), + ), + }; + return issues.length > 0 ? { ok: false, issues } : ok(limits); +} + +/** The limits as the document they are stored as. */ +export function organizationLimitsDocument(limits: OrganizationLimits): Record { + return { + max_concurrent_tasks: limits.maxConcurrentTasks, + command_timeout_minutes: limits.commandTimeoutMinutes, + allowed_modes: limits.allowedModes ? [...limits.allowedModes] : null, + }; +} + +/** Every value an organization chose that the plan does not allow. Empty when all fit. */ +export function organizationLimitsAbovePlan( + limits: OrganizationLimits, + plan: ExecutionSettings, +): DeploymentConfigIssue[] { + const issues: DeploymentConfigIssue[] = []; + if (limits.maxConcurrentTasks !== null && limits.maxConcurrentTasks > plan.maxConcurrentTasks) + issues.push({ + path: '$.max_concurrent_tasks', + message: `The plan allows at most ${String(plan.maxConcurrentTasks)}.`, + }); + if ( + limits.commandTimeoutMinutes !== null && + limits.commandTimeoutMinutes > plan.commandTimeoutMinutes + ) + issues.push({ + path: '$.command_timeout_minutes', + message: `The plan allows at most ${String(plan.commandTimeoutMinutes)}.`, + }); + for (const mode of limits.allowedModes ?? []) + if (!plan.allowedModes.includes(mode)) + issues.push({ path: '$.allowed_modes', message: `The plan does not allow ${mode}.` }); + return issues; +} + +/** + * The limits a run is held to: the plan, lowered wherever the organization chose less. + * + * Minimums and an intersection, never a replacement, so a stored value that is now above a lowered + * plan cannot raise anything. + */ +export function effectiveExecution( + plan: ExecutionSettings, + limits: OrganizationLimits | null, +): ExecutionSettings { + if (!limits) return plan; + return { + maxConcurrentTasks: Math.min( + plan.maxConcurrentTasks, + limits.maxConcurrentTasks ?? plan.maxConcurrentTasks, + ), + commandTimeoutMinutes: Math.min( + plan.commandTimeoutMinutes, + limits.commandTimeoutMinutes ?? plan.commandTimeoutMinutes, + ), + allowedModes: limits.allowedModes + ? plan.allowedModes.filter((mode) => limits.allowedModes?.includes(mode)) + : plan.allowedModes, + // The network ceiling is the service's, like the sandbox it describes: no organization moves it. + maxNetwork: plan.maxNetwork, + }; +} + +/** + * A repository's configuration with the deployment's settings applied: stricter wins everywhere. + * + * The repository's `.code-zero.yml` is untrusted input from the checkout being worked on, so where + * the deployment states a model or a sandbox, the deployment's choice replaces the repository's + * rather than merging with it. The command timeout is the shorter of the two, and the network the + * narrower, whatever executes the run: a repository may ask for less than the deployment allows, + * never more. A repository's own container + * network name is dropped under a deployment sandbox, because `--network host` is a name too. + * + * `execution` is the effective limit for this run — the plan lowered by an organization's limits on + * the managed cloud — and defaults to the deployment's own. + */ +export function applyDeploymentSettings( + config: CodeZeroConfig, + settings: DeploymentSettings, + execution: ExecutionSettings = settings.execution, +): CodeZeroConfig { + const applied: CodeZeroConfig = { + ...config, + agent: { + ...config.agent, + timeoutMs: Math.min(config.agent.timeoutMs, execution.commandTimeoutMinutes * 60_000), + }, + permissions: { + ...config.permissions, + network: narrowerNetwork(config.permissions.network, execution.maxNetwork), + }, + }; + if (settings.model) applied.model = deploymentModel(config.model, settings.model); + if (settings.runner) + applied.runner = { + ...withoutContainerNetwork(config.runner), + isolation: 'container', + engine: settings.runner.engine, + image: settings.runner.image, + }; + return applied; +} + +/** Whether the effective limits let a run start in this mode. */ +export function isModeAllowed(execution: ExecutionSettings, mode: RunMode): boolean { + return execution.allowedModes.includes(mode); +} + +/** + * The deployment's model, keeping the repository's prices only when they describe that same model. + * + * A price is a statement about one model; carried over to another it would make the usage figures + * lie, so it is dropped rather than reused. + */ +function deploymentModel( + repository: CodeZeroConfig['model'], + deployment: ModelSettings, +): CodeZeroConfig['model'] { + if (repository.provider === deployment.provider && repository.name === deployment.name) + return { ...repository }; + return { provider: deployment.provider, name: deployment.name }; +} + +/** The repository's runner policy minus the container network it named for itself. */ +function withoutContainerNetwork(runner: CodeZeroConfig['runner']): CodeZeroConfig['runner'] { + const kept: CodeZeroConfig['runner'] = { ...runner }; + delete kept.network; + return kept; +} + +function narrowerNetwork(left: NetworkPolicy, right: NetworkPolicy): NetworkPolicy { + return NETWORK_POLICIES.indexOf(left) <= NETWORK_POLICIES.indexOf(right) ? left : right; +} + +function executionSection(value: unknown, issues: DeploymentConfigIssue[]): ExecutionSettings { + const defaults = defaultDeploymentSettings.execution; + const section = optionalSection(value, '$.execution', issues); + if (!section) return defaults; + unknownKeys(section, EXECUTION_KEYS, '$.execution', issues); + return { + maxConcurrentTasks: + section.max_concurrent_tasks === undefined + ? defaults.maxConcurrentTasks + : boundedInteger( + section.max_concurrent_tasks, + '$.execution.max_concurrent_tasks', + EXECUTION_LIMITS.maxConcurrentTasks, + issues, + ), + commandTimeoutMinutes: + section.command_timeout_minutes === undefined + ? defaults.commandTimeoutMinutes + : boundedInteger( + section.command_timeout_minutes, + '$.execution.command_timeout_minutes', + EXECUTION_LIMITS.commandTimeoutMinutes, + issues, + ), + allowedModes: + section.allowed_modes === undefined + ? defaults.allowedModes + : modeList(section.allowed_modes, '$.execution.allowed_modes', issues), + maxNetwork: + section.max_network === undefined + ? defaults.maxNetwork + : networkPolicy(section.max_network, '$.execution.max_network', issues), + }; +} + +function networkPolicy( + value: unknown, + path: string, + issues: DeploymentConfigIssue[], +): NetworkPolicy { + if (typeof value === 'string' && isOneOf(NETWORK_POLICIES, value)) return value; + issues.push({ path, message: 'Expected none, restricted, or full.' }); + return 'none'; +} + +function modelSection(value: unknown, issues: DeploymentConfigIssue[]): ModelSettings | null { + const section = optionalSection(value, '$.model', issues); + if (!section) return null; + unknownKeys(section, MODEL_KEYS, '$.model', issues); + const provider = section.provider; + if (typeof provider !== 'string' || !isOneOf(MODEL_PROVIDERS, provider)) { + issues.push({ + path: '$.model.provider', + message: `Expected one of ${MODEL_PROVIDERS.join(', ')}.`, + }); + return null; + } + const name = settingString( + section.name, + '$.model.name', + MODEL_NAME, + 'Expected a model name.', + issues, + ); + return name === undefined ? null : { provider, name }; +} + +function runnerSection(value: unknown, issues: DeploymentConfigIssue[]): SandboxSettings | null { + const section = optionalSection(value, '$.runner', issues); + if (!section) return null; + unknownKeys(section, RUNNER_KEYS, '$.runner', issues); + if (section.isolation !== 'container') { + issues.push({ + path: '$.runner.isolation', + message: + 'Only container isolation can be enforced; remove the runner section to let each repository choose.', + }); + return null; + } + const engine = section.engine ?? 'docker'; + if (typeof engine !== 'string' || !isOneOf(CONTAINER_ENGINES, engine)) { + issues.push({ path: '$.runner.engine', message: 'Expected docker or podman.' }); + return null; + } + const image = settingString( + section.image, + '$.runner.image', + IMAGE_REFERENCE, + 'Expected a container image reference.', + issues, + ); + return image === undefined ? null : { isolation: 'container', engine, image }; +} + +function optionalSection( + value: unknown, + path: string, + issues: DeploymentConfigIssue[], +): Record | undefined { + if (value === undefined || value === null) return undefined; + if (isRecord(value)) return value; + issues.push({ path, message: 'Expected an object.' }); + return undefined; +} + +/** A misspelled key is refused, not ignored: ignoring it is how a limit silently stays the default. */ +function unknownKeys( + section: Record, + known: readonly string[], + path: string, + issues: DeploymentConfigIssue[], +): void { + for (const key of Object.keys(section)) + if (!known.includes(key)) issues.push({ path: `${path}.${key}`, message: 'Unknown setting.' }); +} + +function boundedInteger( + value: unknown, + path: string, + bounds: { readonly minimum: number; readonly maximum: number }, + issues: DeploymentConfigIssue[], +): number { + if ( + typeof value !== 'number' || + !Number.isInteger(value) || + value < bounds.minimum || + value > bounds.maximum + ) { + issues.push({ + path, + message: `Expected an integer from ${String(bounds.minimum)} to ${String(bounds.maximum)}.`, + }); + return bounds.minimum; + } + return value; +} + +/** + * A list of execution modes in the canonical order, with `observe` required. + * + * `observe` is what every unrequested run starts in and the one mode that can never write, so a + * deployment without it would refuse its own default rather than be any safer. + */ +function modeList(value: unknown, path: string, issues: DeploymentConfigIssue[]): RunMode[] { + if ( + !Array.isArray(value) || + !value.every((entry) => typeof entry === 'string' && isOneOf(RUN_MODES, entry)) || + !value.includes('observe') + ) { + issues.push({ path, message: 'Expected a list of execution modes that includes observe.' }); + return ['observe']; + } + return RUN_MODES.filter((mode) => value.includes(mode)); +} + +function settingString( + value: unknown, + path: string, + pattern: RegExp, + message: string, + issues: DeploymentConfigIssue[], +): string | undefined { + if (typeof value !== 'string' || !pattern.test(value.trim())) { + issues.push({ path, message }); + return undefined; + } + const text = value.trim(); + // A setting is shown on a page and exported as a file, so a credential pasted into one would + // leave the deployment through both. Anything the redactor would rewrite is refused instead. + if (redactSecrets(text) !== text) { + issues.push({ path, message: 'Looks like a credential. Credentials stay in the environment.' }); + return undefined; + } + return text; +} + +function nullable(value: unknown, read: () => T): T | null { + return value === undefined || value === null ? null : read(); +} + +function isOneOf(values: readonly T[], value: string): value is T { + return values.some((candidate) => candidate === value); +} + +function ok(value: T): Parsed { + return { ok: true, value }; +} + +function err(path: string, message: string): Parsed { + return { ok: false, issues: [{ path, message }] }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} From 66fa83a86141a9052519292da892c39c7aef6de7 Mon Sep 17 00:00:00 2001 From: RedStar Date: Mon, 19 Oct 2026 12:04:22 +0200 Subject: [PATCH 2/8] feat(database): store deployment and organization settings Add deployment_setting (a singleton row holding the settings document the dashboard source edits) and organization_setting (an organization's own stricter limits on the managed cloud, removed with the organization), with migration 0006 and the queries the composition root reads and writes them through, including the member-role lookup used to authorize changes. --- .../drizzle/0006_normal_felicia_hardy.sql | 18 + .../database/drizzle/meta/0006_snapshot.json | 3362 +++++++++++++++++ packages/database/drizzle/meta/_journal.json | 7 + packages/database/src/index.ts | 10 + packages/database/src/schema/index.ts | 1 + packages/database/src/schema/settings.ts | 44 + packages/database/src/settings.test.ts | 111 + packages/database/src/settings.ts | 119 + 8 files changed, 3672 insertions(+) create mode 100644 packages/database/drizzle/0006_normal_felicia_hardy.sql create mode 100644 packages/database/drizzle/meta/0006_snapshot.json create mode 100644 packages/database/src/schema/settings.ts create mode 100644 packages/database/src/settings.test.ts create mode 100644 packages/database/src/settings.ts diff --git a/packages/database/drizzle/0006_normal_felicia_hardy.sql b/packages/database/drizzle/0006_normal_felicia_hardy.sql new file mode 100644 index 0000000..8662d7f --- /dev/null +++ b/packages/database/drizzle/0006_normal_felicia_hardy.sql @@ -0,0 +1,18 @@ +CREATE TABLE "deployment_setting" ( + "id" text PRIMARY KEY NOT NULL, + "document" jsonb NOT NULL, + "updated_by" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "deployment_setting_singleton" CHECK ("deployment_setting"."id" = 'deployment') +); +--> statement-breakpoint +CREATE TABLE "organization_setting" ( + "organization_id" text PRIMARY KEY NOT NULL, + "document" jsonb NOT NULL, + "updated_by" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "organization_setting" ADD CONSTRAINT "organization_setting_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/packages/database/drizzle/meta/0006_snapshot.json b/packages/database/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..2d94f46 --- /dev/null +++ b/packages/database/drizzle/meta/0006_snapshot.json @@ -0,0 +1,3362 @@ +{ + "id": "4c305f87-c67c-412c-ae9e-acfdbc7b25c0", + "prevId": "0a9ebef7-8655-4e0a-95db-724cf281105f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 86400000 + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_key_config_id_idx": { + "name": "api_key_config_id_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_reference_id_idx": { + "name": "api_key_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_unique": { + "name": "api_key_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.deployment_setting": { + "name": "deployment_setting", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "deployment_setting_singleton": { + "name": "deployment_setting_singleton", + "value": "\"deployment_setting\".\"id\" = 'deployment'" + } + }, + "isRLSEnabled": false + }, + "public.device_code": { + "name": "device_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "device_code": { + "name": "device_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "polling_interval": { + "name": "polling_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_code_device_code_unique": { + "name": "device_code_device_code_unique", + "columns": [ + { + "expression": "device_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_user_code_unique": { + "name": "device_code_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_user_id_idx": { + "name": "device_code_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_code_expires_at_idx": { + "name": "device_code_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_code_user_id_user_id_fk": { + "name": "device_code_user_id_user_id_fk", + "tableFrom": "device_code", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite": { + "name": "invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'app'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_role": { + "name": "organization_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preset_seat_limit": { + "name": "preset_seat_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pre_created_user_id": { + "name": "pre_created_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inviter_name": { + "name": "inviter_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_email": { + "name": "inviter_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by_user_id": { + "name": "revoked_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_token_hash_unique": { + "name": "invite_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_email_idx": { + "name": "invite_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_status_idx": { + "name": "invite_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_organization_id_idx": { + "name": "invite_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_pre_created_user_id_idx": { + "name": "invite_pre_created_user_id_idx", + "columns": [ + { + "expression": "pre_created_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_created_by_user_id_idx": { + "name": "invite_created_by_user_id_idx", + "columns": [ + { + "expression": "created_by_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invite_expires_at_idx": { + "name": "invite_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invite_use": { + "name": "invite_use", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invite_id": { + "name": "invite_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_by_user_id": { + "name": "used_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invitee_email": { + "name": "invitee_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invite_use_invite_id_idx": { + "name": "invite_use_invite_id_idx", + "columns": [ + { + "expression": "invite_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invite_use_invite_id_invite_id_fk": { + "name": "invite_use_invite_id_invite_id_fk", + "tableFrom": "invite_use", + "tableTo": "invite", + "columnsFrom": ["invite_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_organization_user_unique": { + "name": "member_organization_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_user_id_idx": { + "name": "member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "seat_limit": { + "name": "seat_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organization_slug_unique": { + "name": "organization_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_setting": { + "name": "organization_setting", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "document": { + "name": "document", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_setting_organization_id_organization_id_fk": { + "name": "organization_setting_organization_id_organization_id_fk", + "tableFrom": "organization_setting", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "passkey_user_id_idx": { + "name": "passkey_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repository": { + "name": "repository", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'github'" + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_path": { + "name": "checkout_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'observe'" + }, + "poll_enabled": { + "name": "poll_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "repository_checkout_path_unique": { + "name": "repository_checkout_path_unique", + "columns": [ + { + "expression": "checkout_path", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repository_provider_owner_name_unique": { + "name": "repository_provider_owner_name_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repository_poll_enabled_idx": { + "name": "repository_poll_enabled_idx", + "columns": [ + { + "expression": "poll_enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_connection_binding": { + "name": "scim_connection_binding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_key": { + "name": "connection_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_status": { + "name": "decommission_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "decommission_cursor_user_id": { + "name": "decommission_cursor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_reconciled_user_count": { + "name": "decommission_reconciled_user_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_batch_count": { + "name": "decommission_batch_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_revision": { + "name": "decommission_revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "decommission_completed_at": { + "name": "decommission_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_id": { + "name": "decommission_lease_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommission_lease_expires_at": { + "name": "decommission_lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_connection_binding_connection_id_idx": { + "name": "scim_connection_binding_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_connection_binding_connection_key_unique": { + "name": "scim_connection_binding_connection_key_unique", + "nullsNotDistinct": false, + "columns": ["connection_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group": { + "name": "scim_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_key": { + "name": "display_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_connection_id_idx": { + "name": "scim_group_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_provisioning_domain_id_idx": { + "name": "scim_group_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_display_name_key_unique": { + "name": "scim_group_display_name_key_unique", + "nullsNotDistinct": false, + "columns": ["display_name_key"] + }, + "scim_group_external_id_key_unique": { + "name": "scim_group_external_id_key_unique", + "nullsNotDistinct": false, + "columns": ["external_id_key"] + }, + "scim_group_order_key_unique": { + "name": "scim_group_order_key_unique", + "nullsNotDistinct": false, + "columns": ["order_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_group_member": { + "name": "scim_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_key": { + "name": "membership_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_group_member_connection_id_idx": { + "name": "scim_group_member_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_group_id_idx": { + "name": "scim_group_member_group_id_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_group_member_scim_user_id_idx": { + "name": "scim_group_member_scim_user_id_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_group_member_group_id_scim_group_id_fk": { + "name": "scim_group_member_group_id_scim_group_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_group", + "columnsFrom": ["group_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_group_member_scim_user_id_scim_user_id_fk": { + "name": "scim_group_member_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_group_member", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_group_member_membership_key_unique": { + "name": "scim_group_member_membership_key_unique", + "nullsNotDistinct": false, + "columns": ["membership_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_identity_tombstone": { + "name": "scim_identity_tombstone", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_identity_tombstone_connection_id_idx": { + "name": "scim_identity_tombstone_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstone_provisioning_domain_id_idx": { + "name": "scim_identity_tombstone_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_identity_tombstone_user_id_idx": { + "name": "scim_identity_tombstone_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_identity_tombstone_user_id_user_id_fk": { + "name": "scim_identity_tombstone_user_id_user_id_fk", + "tableFrom": "scim_identity_tombstone", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_identity_tombstone_external_id_key_unique": { + "name": "scim_identity_tombstone_external_id_key_unique", + "nullsNotDistinct": false, + "columns": ["external_id_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_connection": { + "name": "scim_managed_connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "creation_request_id": { + "name": "creation_request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "decommission_started_at": { + "name": "decommission_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommission_started_by": { + "name": "decommission_started_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decommissioned_by": { + "name": "decommissioned_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_managed_connection_provisioning_domain_id_idx": { + "name": "scim_managed_connection_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_connection_creation_request_id_unique": { + "name": "scim_managed_connection_creation_request_id_unique", + "nullsNotDistinct": false, + "columns": ["creation_request_id"] + }, + "scim_managed_connection_connection_id_unique": { + "name": "scim_managed_connection_connection_id_unique", + "nullsNotDistinct": false, + "columns": ["connection_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_connection_event": { + "name": "scim_managed_connection_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_record_id": { + "name": "connection_record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sequence": { + "name": "sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_managed_connection_event_connection_record_id_idx": { + "name": "scim_managed_connection_event_connection_record_id_idx", + "columns": [ + { + "expression": "connection_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_managed_connection_event_connection_record_id_scim_managed_connection_id_fk": { + "name": "scim_managed_connection_event_connection_record_id_scim_managed_connection_id_fk", + "tableFrom": "scim_managed_connection_event", + "tableTo": "scim_managed_connection", + "columnsFrom": ["connection_record_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_connection_event_event_key_unique": { + "name": "scim_managed_connection_event_event_key_unique", + "nullsNotDistinct": false, + "columns": ["event_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_managed_credential": { + "name": "scim_managed_credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_record_id": { + "name": "connection_record_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_digest": { + "name": "token_digest", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash_version": { + "name": "hash_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_slot_key": { + "name": "active_slot_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_scopes": { + "name": "serialized_scopes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "decommissioned_at": { + "name": "decommissioned_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "scim_managed_credential_connection_record_id_idx": { + "name": "scim_managed_credential_connection_record_id_idx", + "columns": [ + { + "expression": "connection_record_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_managed_credential_connection_record_id_scim_managed_connection_id_fk": { + "name": "scim_managed_credential_connection_record_id_scim_managed_connection_id_fk", + "tableFrom": "scim_managed_credential", + "tableTo": "scim_managed_connection", + "columnsFrom": ["connection_record_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_managed_credential_credential_id_unique": { + "name": "scim_managed_credential_credential_id_unique", + "nullsNotDistinct": false, + "columns": ["credential_id"] + }, + "scim_managed_credential_active_slot_key_unique": { + "name": "scim_managed_credential_active_slot_key_unique", + "nullsNotDistinct": false, + "columns": ["active_slot_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_projection_grant": { + "name": "scim_projection_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scim_user_id": { + "name": "scim_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_kind": { + "name": "source_kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_value": { + "name": "source_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant_key": { + "name": "grant_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_projection_grant_connection_id_idx": { + "name": "scim_projection_grant_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_provisioning_domain_id_idx": { + "name": "scim_projection_grant_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_scim_user_id_idx": { + "name": "scim_projection_grant_scim_user_id_idx", + "columns": [ + { + "expression": "scim_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_projection_grant_user_id_idx": { + "name": "scim_projection_grant_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_projection_grant_scim_user_id_scim_user_id_fk": { + "name": "scim_projection_grant_scim_user_id_scim_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "scim_user", + "columnsFrom": ["scim_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scim_projection_grant_user_id_user_id_fk": { + "name": "scim_projection_grant_user_id_user_id_fk", + "tableFrom": "scim_projection_grant", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_projection_grant_grant_key_unique": { + "name": "scim_projection_grant_grant_key_unique", + "nullsNotDistinct": false, + "columns": ["grant_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_subject": { + "name": "scim_subject", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_source_id": { + "name": "profile_source_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_subject_profile_source_id_idx": { + "name": "scim_subject_profile_source_id_idx", + "columns": [ + { + "expression": "profile_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_subject_user_id_user_id_fk": { + "name": "scim_subject_user_id_user_id_fk", + "tableFrom": "scim_subject", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_subject_user_id_unique": { + "name": "scim_subject_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scim_user": { + "name": "scim_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provisioning_domain_id": { + "name": "provisioning_domain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connection_user_key": { + "name": "connection_user_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name": { + "name": "user_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_name_key": { + "name": "user_name_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "work_email_value_index": { + "name": "work_email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_value_index": { + "name": "email_value_index", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "formatted_name": { + "name": "formatted_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "given_name": { + "name": "given_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "family_name": { + "name": "family_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serialized_emails": { + "name": "serialized_emails", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "serialized_attributes": { + "name": "serialized_attributes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id_key": { + "name": "external_id_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "scim_user_connection_id_idx": { + "name": "scim_user_connection_id_idx", + "columns": [ + { + "expression": "connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_provisioning_domain_id_idx": { + "name": "scim_user_provisioning_domain_id_idx", + "columns": [ + { + "expression": "provisioning_domain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scim_user_user_id_idx": { + "name": "scim_user_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scim_user_user_id_user_id_fk": { + "name": "scim_user_user_id_user_id_fk", + "tableFrom": "scim_user", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "scim_user_connection_user_key_unique": { + "name": "scim_user_connection_user_key_unique", + "nullsNotDistinct": false, + "columns": ["connection_user_key"] + }, + "scim_user_user_name_key_unique": { + "name": "scim_user_user_name_key_unique", + "nullsNotDistinct": false, + "columns": ["user_name_key"] + }, + "scim_user_external_id_key_unique": { + "name": "scim_user_external_id_key_unique", + "nullsNotDistinct": false, + "columns": ["external_id_key"] + }, + "scim_user_order_key_unique": { + "name": "scim_user_order_key_unique", + "nullsNotDistinct": false, + "columns": ["order_key"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_impersonated_by_user_id_fk": { + "name": "session_impersonated_by_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["impersonated_by"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain_verified": { + "name": "domain_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "sso_provider_provider_id_unique": { + "name": "sso_provider_provider_id_unique", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.two_factor": { + "name": "two_factor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backup_codes": { + "name": "backup_codes", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_verification_count": { + "name": "failed_verification_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "locked_until": { + "name": "locked_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "two_factor_secret_idx": { + "name": "two_factor_secret_idx", + "columns": [ + { + "expression": "secret", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "two_factor_user_id_idx": { + "name": "two_factor_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "two_factor_user_id_user_id_fk": { + "name": "two_factor_user_id_user_id_fk", + "tableFrom": "two_factor", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_login_method": { + "name": "last_login_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "two_factor_enabled": { + "name": "two_factor_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index 687d28f..49be302 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1792314139137, "tag": "0005_tricky_dexter_bennett", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1792341556244, + "tag": "0006_normal_felicia_hardy", + "breakpoints": true } ] } diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 9626237..573e4ba 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -18,15 +18,25 @@ export { type RepositoryRecord, type WatchedRepositoryRecord, } from './repositories.js'; +export { + organizationMemberRole, + readDeploymentSettings, + readOrganizationSettings, + saveDeploymentSettings, + saveOrganizationSettings, + type StoredSettings, +} from './settings.js'; export { account, apiKey, + deploymentSetting, deviceCode, invitation, invite, inviteUse, member, organization, + organizationSetting, passkey, repository, schema, diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 062d042..8d123f0 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -28,6 +28,7 @@ export { invite, inviteUse } from './enrollment.js'; export { invitation, member, organization } from './organization.js'; export { passkey } from './passkey.js'; export { repository } from './repository.js'; +export { deploymentSetting, organizationSetting } from './settings.js'; export { scimConnectionBinding, scimGroup, diff --git a/packages/database/src/schema/settings.ts b/packages/database/src/schema/settings.ts new file mode 100644 index 0000000..b409fa2 --- /dev/null +++ b/packages/database/src/schema/settings.ts @@ -0,0 +1,44 @@ +import { sql } from 'drizzle-orm'; +import { check, jsonb, pgTable, text } from 'drizzle-orm/pg-core'; + +import { timestampColumns } from './columns.js'; +import { organization } from './organization.js'; + +/** + * The deployment's settings, when the dashboard is their source. + * + * One row at most: a deployment has one set of settings, so the primary key is pinned to a single + * value and a second row cannot exist to disagree with the first. The document is the same YAML + * shape the deployment file holds, stored as JSON; this package keeps it opaque, and + * `@code-zero/config` validates it on every write and every read, so a row edited by hand is + * refused rather than trusted. + * + * Deliberately absent from the `schema` object Better Auth resolves models against: nothing about + * authentication reads or writes it. + */ +export const deploymentSetting = pgTable( + 'deployment_setting', + { + id: text('id').primaryKey(), + document: jsonb('document').notNull(), + /** Who saved this version, as the audit trail names them. */ + updatedBy: text('updated_by').notNull(), + ...timestampColumns, + }, + (table) => [check('deployment_setting_singleton', sql`${table.id} = 'deployment'`)], +); + +/** + * The stricter limits an organization chose for its own work on the managed cloud. + * + * Keyed by the organization and removed with it. Only ever lower than the plan when written; the + * reader still takes the minimum, because the plan can be lowered after a row was saved. + */ +export const organizationSetting = pgTable('organization_setting', { + organizationId: text('organization_id') + .primaryKey() + .references(() => organization.id, { onDelete: 'cascade' }), + document: jsonb('document').notNull(), + updatedBy: text('updated_by').notNull(), + ...timestampColumns, +}); diff --git a/packages/database/src/settings.test.ts b/packages/database/src/settings.test.ts new file mode 100644 index 0000000..c928cd8 --- /dev/null +++ b/packages/database/src/settings.test.ts @@ -0,0 +1,111 @@ +import { fileURLToPath } from 'node:url'; + +import { drizzle } from 'drizzle-orm/pglite'; +import { migrate } from 'drizzle-orm/pglite/migrator'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { member, organization, schema, user } from './schema/index.js'; +import { + organizationMemberRole, + readDeploymentSettings, + readOrganizationSettings, + saveDeploymentSettings, + saveOrganizationSettings, +} from './settings.js'; + +const MIGRATIONS = fileURLToPath(new URL('../drizzle', import.meta.url)); +const SINGLETON_VIOLATION = /deployment_setting_singleton/; + +/** + * An in-memory Postgres (PGlite) migrated with the checked-in migrations, so these tests read and + * write through the same SQL a deployment runs, constraints included. PGlite reaches this package + * as a peer `drizzle-orm` already resolved; nothing here opens a socket. + */ +const database = drizzle({ schema }); + +beforeAll(async () => { + await migrate(database, { migrationsFolder: MIGRATIONS }); + await database.insert(user).values([ + { id: 'u-owner', name: 'Owner', email: 'owner@example.test' }, + { id: 'u-outsider', name: 'Outsider', email: 'outsider@example.test' }, + ]); + await database.insert(organization).values([ + { id: 'org-a', name: 'A', slug: 'a' }, + { id: 'org-b', name: 'B', slug: 'b' }, + ]); + await database.insert(member).values({ + id: 'm-1', + organizationId: 'org-a', + userId: 'u-owner', + role: 'owner', + }); +}, 60_000); + +afterAll(async () => { + await database.$client.close(); +}); + +describe('deployment settings', () => { + it('reads nothing before the dashboard has saved anything', async () => { + await expect(readDeploymentSettings(database)).resolves.toBeNull(); + }); + + it('keeps the latest saved document and who saved it', async () => { + await saveDeploymentSettings( + database, + { execution: { max_concurrent_tasks: 2 } }, + 'ops@example.test', + ); + await saveDeploymentSettings( + database, + { execution: { max_concurrent_tasks: 3 } }, + 'admin@example.test', + ); + + await expect(readDeploymentSettings(database)).resolves.toMatchObject({ + document: { execution: { max_concurrent_tasks: 3 } }, + updatedBy: 'admin@example.test', + }); + }); + + it('refuses a second settings row at the database, not only in this package', async () => { + await expect( + database.$client.query( + `insert into deployment_setting (id, document, updated_by) values ('other', '{}', 'x')`, + ), + ).rejects.toThrow(SINGLETON_VIOLATION); + }); +}); + +describe('organization settings', () => { + it('keeps each organization to its own row', async () => { + await saveOrganizationSettings( + database, + 'org-a', + { max_concurrent_tasks: 1 }, + 'owner@example.test', + ); + + await expect(readOrganizationSettings(database, 'org-a')).resolves.toMatchObject({ + document: { max_concurrent_tasks: 1 }, + }); + await expect(readOrganizationSettings(database, 'org-b')).resolves.toBeNull(); + }); + + it('removes the limits with the organization', async () => { + await saveOrganizationSettings(database, 'org-b', { max_run_minutes: 5 }, 'owner@example.test'); + await database.$client.query(`delete from organization where id = 'org-b'`); + + await expect(readOrganizationSettings(database, 'org-b')).resolves.toBeNull(); + }); +}); + +describe('organizationMemberRole', () => { + it('answers with the role a member holds', async () => { + await expect(organizationMemberRole(database, 'org-a', 'u-owner')).resolves.toBe('owner'); + }); + + it('answers null for an account outside the organization', async () => { + await expect(organizationMemberRole(database, 'org-a', 'u-outsider')).resolves.toBeNull(); + }); +}); diff --git a/packages/database/src/settings.ts b/packages/database/src/settings.ts new file mode 100644 index 0000000..6e7a565 --- /dev/null +++ b/packages/database/src/settings.ts @@ -0,0 +1,119 @@ +import { and, eq } from 'drizzle-orm'; +import type { PgDatabase, PgQueryResultHKT } from 'drizzle-orm/pg-core'; + +import type { Schema } from './schema/index.js'; +import { member } from './schema/organization.js'; +import { deploymentSetting, organizationSetting } from './schema/settings.js'; + +/** + * Any Drizzle Postgres client opened on this package's schema. + * + * The deployment passes its `postgres-js` client (`Database`); the tests pass an in-memory PGlite + * one, which speaks the same query builder. Typed by what the queries below use rather than by one + * driver, so neither caller has to pretend to be the other. + */ +type SettingsDatabase = PgDatabase; + +/** The one key the deployment's settings row is stored under. */ +const DEPLOYMENT_SETTINGS_ID = 'deployment'; + +/** + * A stored settings document and who saved it. + * + * The document stays `unknown` here: this package stores what it is given and returns what it + * holds. Deciding whether a document is valid is `@code-zero/config`'s job, and its caller asks on + * every read, so a row written by hand cannot reach a run unchecked. + */ +export interface StoredSettings { + document: unknown; + updatedBy: string; + updatedAt: Date; +} + +/** The deployment's stored settings, or null when the dashboard has never saved any. */ +export async function readDeploymentSettings( + database: SettingsDatabase, +): Promise { + const [row] = await database + .select() + .from(deploymentSetting) + .where(eq(deploymentSetting.id, DEPLOYMENT_SETTINGS_ID)) + .limit(1); + return row ? toStored(row) : null; +} + +/** Replace the deployment's settings. The caller has already validated the document. */ +export async function saveDeploymentSettings( + database: SettingsDatabase, + document: Record, + updatedBy: string, +): Promise { + const updatedAt = new Date(); + const [row] = await database + .insert(deploymentSetting) + .values({ id: DEPLOYMENT_SETTINGS_ID, document, updatedBy, updatedAt }) + .onConflictDoUpdate({ + target: deploymentSetting.id, + set: { document, updatedBy, updatedAt }, + }) + .returning(); + if (!row) throw new Error('The deployment settings could not be saved'); + return toStored(row); +} + +/** An organization's stored limits, or null when it never chose any. */ +export async function readOrganizationSettings( + database: SettingsDatabase, + organizationId: string, +): Promise { + const [row] = await database + .select() + .from(organizationSetting) + .where(eq(organizationSetting.organizationId, organizationId)) + .limit(1); + return row ? toStored(row) : null; +} + +/** Replace an organization's limits. The caller has already checked the member and the plan. */ +export async function saveOrganizationSettings( + database: SettingsDatabase, + organizationId: string, + document: Record, + updatedBy: string, +): Promise { + const updatedAt = new Date(); + const [row] = await database + .insert(organizationSetting) + .values({ organizationId, document, updatedBy, updatedAt }) + .onConflictDoUpdate({ + target: organizationSetting.organizationId, + set: { document, updatedBy, updatedAt }, + }) + .returning(); + if (!row) throw new Error('The organization settings could not be saved'); + return toStored(row); +} + +/** + * The role an account holds in an organization, or null when it is not a member. + * + * Read from the membership table on every call rather than from the session: a session remembers + * which organization it last acted in, not whether the account still belongs to it, so a removed + * member's session must not keep answering for the organization. + */ +export async function organizationMemberRole( + database: SettingsDatabase, + organizationId: string, + userId: string, +): Promise { + const [row] = await database + .select({ role: member.role }) + .from(member) + .where(and(eq(member.organizationId, organizationId), eq(member.userId, userId))) + .limit(1); + return row?.role ?? null; +} + +function toStored(row: { document: unknown; updatedBy: string; updatedAt: Date }): StoredSettings { + return { document: row.document, updatedBy: row.updatedBy, updatedAt: row.updatedAt }; +} From 38d6c7eeec7444718c25aaea8286c83601f03deb Mon Sep 17 00:00:00 2001 From: RedStar Date: Mon, 19 Oct 2026 12:04:29 +0200 Subject: [PATCH 3/8] feat(api): add settings procedures and hold every run to them Add settings.get, settings.update, settings.export and settings.organization.get/update. Self-hosted, only an admin session reads them, and writes are refused on the server unless the dashboard is the source. On the managed cloud a customer sees service status (configured or not, nothing else) and the plan's limits; an organization owner or admin may lower the organization's limits, never above the plan, with membership checked in the store on every call. API keys and operator tokens can neither read nor change settings. Refusals are audited. tasks.create, runTask and the scheduler apply the resulting RunPolicy: invalid settings refuse every run, allowed modes are checked before a task is recorded, concurrency is capped deployment-wide and per organization, and the settings are applied over .code-zero.yml with the stricter value winning. --- packages/api/src/access.ts | 24 +- packages/api/src/control-plane.test.ts | 57 +++ packages/api/src/control-plane.ts | 78 +++- packages/api/src/index.ts | 15 + packages/api/src/operations.test.ts | 182 +++++++- packages/api/src/operations.ts | 241 ++++++---- packages/api/src/orpc/actor.ts | 14 + packages/api/src/orpc/auth.ts | 16 +- packages/api/src/orpc/router.ts | 106 ++++- packages/api/src/orpc/settings.test.ts | 612 +++++++++++++++++++++++++ packages/api/src/orpc/settings.ts | 421 +++++++++++++++++ packages/api/src/settings.test.ts | 78 ++++ packages/api/src/settings.ts | 170 +++++++ 13 files changed, 1889 insertions(+), 125 deletions(-) create mode 100644 packages/api/src/orpc/actor.ts create mode 100644 packages/api/src/orpc/settings.test.ts create mode 100644 packages/api/src/orpc/settings.ts create mode 100644 packages/api/src/settings.test.ts create mode 100644 packages/api/src/settings.ts diff --git a/packages/api/src/access.ts b/packages/api/src/access.ts index a0b96a9..02831dc 100644 --- a/packages/api/src/access.ts +++ b/packages/api/src/access.ts @@ -30,6 +30,14 @@ export interface Principal { * read it back would let one audit itself. */ admin: boolean; + /** + * The account behind a `session` principal: its id, and the organization the session last chose + * to act in. That organization is the session's claim, not a grant — membership is read from the + * store before anything is decided by it. Absent on every other kind. + */ + account?: { userId: string; activeOrganizationId: string | null }; + /** The organization that owns an `apiKey` principal, as Better Auth verified it. */ + organizationId?: string; } /** @@ -122,8 +130,18 @@ export function authenticate( * {@link DEFAULT_MODES} an ungranted token gets. Repository targeting is a separate grant either * way, answered by the composition root against the deployment's store. */ -export function sessionPrincipal(name: string, isAdmin: boolean): Principal { - return { name, kind: 'session', modes: isAdmin ? ADMIN_MODES : DEFAULT_MODES, admin: isAdmin }; +export function sessionPrincipal( + name: string, + isAdmin: boolean, + account?: { userId: string; activeOrganizationId: string | null }, +): Principal { + return { + name, + kind: 'session', + modes: isAdmin ? ADMIN_MODES : DEFAULT_MODES, + admin: isAdmin, + ...(account ? { account } : {}), + }; } /** The API-key permission that lets a key submit tasks, as `@code-zero/auth` declares it. */ @@ -142,6 +160,7 @@ const TASK_CREATE_PERMISSION = { resource: 'task', action: 'create' } as const; export function apiKeyPrincipal( name: string, permissions: Readonly> | null | undefined, + organizationId?: string, ): Principal { const actions = permissions?.[TASK_CREATE_PERMISSION.resource] ?? []; return { @@ -149,5 +168,6 @@ export function apiKeyPrincipal( kind: 'apiKey', modes: actions.includes(TASK_CREATE_PERMISSION.action) ? DEFAULT_MODES : [], admin: false, + ...(organizationId === undefined ? {} : { organizationId }), }; } diff --git a/packages/api/src/control-plane.test.ts b/packages/api/src/control-plane.test.ts index e689f1d..0c1a999 100644 --- a/packages/api/src/control-plane.test.ts +++ b/packages/api/src/control-plane.test.ts @@ -272,4 +272,61 @@ describe('TaskScheduler', () => { await expect(scheduler.schedule('acme/site', async () => 'ran')).resolves.toBe('ran'); expect(scheduler.snapshot()).toMatchObject({ queued: 1 }); }); + + it('holds a job to the concurrency the deployment settings gave it', () => { + const scheduler = new TaskScheduler({ + maxConcurrent: 4, + maxQueued: 10, + maxConcurrentPerRepository: 1, + }); + void scheduler.schedule('one', () => new Promise(() => undefined), { maxConcurrent: 1 }); + void scheduler.schedule('two', () => new Promise(() => undefined), { maxConcurrent: 1 }); + expect(scheduler.snapshot()).toMatchObject({ active: 1, queued: 1 }); + }); + + it('runs above its own default when the deployment settings allow more', () => { + const scheduler = new TaskScheduler({ + maxConcurrent: 1, + maxQueued: 10, + maxConcurrentPerRepository: 1, + }); + for (const repository of ['one', 'two', 'three']) + void scheduler.schedule(repository, () => new Promise(() => undefined), { + maxConcurrent: 3, + }); + expect(scheduler.snapshot()).toMatchObject({ active: 3, queued: 0 }); + }); + + it('keeps an organization to its own lower limit while others keep running', () => { + const scheduler = new TaskScheduler({ + maxConcurrent: 4, + maxQueued: 10, + maxConcurrentPerRepository: 1, + }); + const organization = { key: 'org-a', maxConcurrent: 1 }; + void scheduler.schedule('one', () => new Promise(() => undefined), { group: organization }); + void scheduler.schedule('two', () => new Promise(() => undefined), { group: organization }); + void scheduler.schedule('three', () => new Promise(() => undefined)); + expect(scheduler.snapshot()).toMatchObject({ active: 2, queued: 1 }); + }); + + it('starts the queued organization job once its running one finishes', async () => { + const scheduler = new TaskScheduler({ + maxConcurrent: 4, + maxQueued: 10, + maxConcurrentPerRepository: 1, + }); + const organization = { key: 'org-a', maxConcurrent: 1 }; + const releases: Array<() => void> = []; + const first = scheduler.schedule( + 'one', + () => new Promise((resolve) => releases.push(() => resolve('first'))), + { group: organization }, + ); + const second = scheduler.schedule('two', async () => 'second', { group: organization }); + expect(scheduler.snapshot()).toMatchObject({ active: 1, queued: 1 }); + releases.shift()?.(); + await expect(first).resolves.toBe('first'); + await expect(second).resolves.toBe('second'); + }); }); diff --git a/packages/api/src/control-plane.ts b/packages/api/src/control-plane.ts index 53e91d4..3ad830c 100644 --- a/packages/api/src/control-plane.ts +++ b/packages/api/src/control-plane.ts @@ -32,6 +32,14 @@ export interface StoredTask extends DashboardTask { * mutation after verification cannot ride along under the run's evidence. */ changedFileSnapshot?: ChangedFileSnapshot[]; + /** + * The organization the run was started for on the managed cloud, fixed when it was created. + * + * Anything later done about this task — deciding it, retrying it — answers to this organization, + * not to whichever one the session that comes back has selected by then. Absent on a self-hosted + * deployment and for work the deployment started itself. + */ + organizationId?: string; } export interface TaskStore { @@ -220,6 +228,20 @@ export interface SchedulerOptions { maxConcurrentPerRepository: number; } +/** + * The limits one job was submitted under. + * + * Carried per job rather than fixed on the scheduler, because they come from settings that change + * while the process runs: a job queued under the old limit keeps it, and the next one is held to the + * new one, with no restart and no scheduler to rebuild. + */ +export interface ScheduleLimits { + /** How many jobs may run at once when this one starts. Replaces the scheduler's own ceiling. */ + maxConcurrent?: number; + /** At most `maxConcurrent` jobs sharing `key` run at once: one organization's own, lower ceiling. */ + group?: { key: string; maxConcurrent: number }; +} + export interface SchedulerSnapshot { active: number; queued: number; @@ -235,13 +257,15 @@ export class TaskQueueQuotaError extends Error { interface QueuedJob { repository: string; + limits: ScheduleLimits; start: () => Promise; } -/** Bounded FIFO scheduler with a separate per-repository fairness quota. */ +/** Bounded FIFO scheduler with separate per-repository and per-group fairness quotas. */ export class TaskScheduler { private active = 0; private readonly activeByRepository = new Map(); + private readonly activeByGroup = new Map(); private readonly queue: QueuedJob[] = []; constructor(private readonly options: SchedulerOptions) { @@ -250,12 +274,16 @@ export class TaskScheduler { assertPositiveInteger(options.maxConcurrentPerRepository, 'maxConcurrentPerRepository'); } - schedule(repository: string, run: () => Promise): Promise { - if (this.queue.length >= this.options.maxQueued && !this.hasImmediateCapacity(repository)) + schedule(repository: string, run: () => Promise, limits: ScheduleLimits = {}): Promise { + if (limits.maxConcurrent !== undefined) + assertPositiveInteger(limits.maxConcurrent, 'maxConcurrent'); + if (limits.group) assertPositiveInteger(limits.group.maxConcurrent, 'group.maxConcurrent'); + if (this.queue.length >= this.options.maxQueued && !this.canStart({ repository, limits })) throw new TaskQueueQuotaError('Task queue capacity is exhausted'); return new Promise((resolve, reject) => { this.queue.push({ repository, + limits, start: async () => { try { resolve(await run()); @@ -277,45 +305,51 @@ export class TaskScheduler { } /** - * Whether a new job for this repository would start synchronously in {@link drain}. + * Whether a job with these limits would start synchronously in {@link drain}. * * Only jobs without immediate capacity occupy the queue, so `maxQueued` must bound exactly those: - * a job blocked by its repository quota counts against the queue even while global capacity is - * free, otherwise per-repository-blocked submissions could grow the queue without limit. + * a job blocked by its repository or group quota counts against the queue even while global + * capacity is free, otherwise blocked submissions could grow the queue without limit. */ - private hasImmediateCapacity(repository: string): boolean { + private canStart(job: Pick): boolean { + const { group } = job.limits; return ( - this.active < this.options.maxConcurrent && - (this.activeByRepository.get(repository) ?? 0) < this.options.maxConcurrentPerRepository + this.active < (job.limits.maxConcurrent ?? this.options.maxConcurrent) && + (this.activeByRepository.get(job.repository) ?? 0) < + this.options.maxConcurrentPerRepository && + (!group || (this.activeByGroup.get(group.key) ?? 0) < group.maxConcurrent) ); } private drain(): void { - while (this.active < this.options.maxConcurrent) { - const index = this.queue.findIndex( - (job) => - (this.activeByRepository.get(job.repository) ?? 0) < - this.options.maxConcurrentPerRepository, - ); + for (;;) { + const index = this.queue.findIndex((job) => this.canStart(job)); if (index < 0) return; const [job] = this.queue.splice(index, 1); if (!job) return; this.active += 1; - this.activeByRepository.set( - job.repository, - (this.activeByRepository.get(job.repository) ?? 0) + 1, - ); + increment(this.activeByRepository, job.repository); + if (job.limits.group) increment(this.activeByGroup, job.limits.group.key); void job.start().finally(() => { this.active -= 1; - const repositoryActive = (this.activeByRepository.get(job.repository) ?? 1) - 1; - if (repositoryActive === 0) this.activeByRepository.delete(job.repository); - else this.activeByRepository.set(job.repository, repositoryActive); + decrement(this.activeByRepository, job.repository); + if (job.limits.group) decrement(this.activeByGroup, job.limits.group.key); this.drain(); }); } } } +function increment(counts: Map, key: string): void { + counts.set(key, (counts.get(key) ?? 0) + 1); +} + +function decrement(counts: Map, key: string): void { + const remaining = (counts.get(key) ?? 1) - 1; + if (remaining === 0) counts.delete(key); + else counts.set(key, remaining); +} + function sanitizeTask(task: StoredTask, secrets: readonly string[]): StoredTask { const value = redactDeep(task, secrets); if (!isStoredTask(value)) throw new Error('Refusing to persist an invalid task record'); diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index bce8013..39374bf 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -48,6 +48,7 @@ export { type DeliveryClaim, type DeliveryClaimStore, type KeyValueStorage, + type ScheduleLimits, type SchedulerOptions, type SchedulerSnapshot, type StoredTask, @@ -83,4 +84,18 @@ export type { RepositoryMode, RepositoryRecord, } from './repositories.js'; +export { deploymentServices } from './settings.js'; +export type { + DashboardSettingsAuthority, + DeploymentConnections, + DeploymentSettingsAuthority, + FileSettingsAuthority, + OrganizationMembership, + OrganizationSettingsStore, + RunPolicy, + ServiceName, + ServiceStatus, + SettingsState, +} from './settings.js'; +export type { OrganizationSettingsView, SettingsView } from './orpc/settings.js'; export { FileKeyValueStorage } from './storage.js'; diff --git a/packages/api/src/operations.test.ts b/packages/api/src/operations.test.ts index 46ea569..e39a2c4 100644 --- a/packages/api/src/operations.test.ts +++ b/packages/api/src/operations.test.ts @@ -5,7 +5,13 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { promisify } from 'node:util'; -import { RunnerPool, type Runner, type SandboxProvider } from '@code-zero/runner'; +import { defaultDeploymentSettings, type DeploymentSettings } from '@code-zero/config'; +import { + RunnerPool, + type Runner, + type SandboxProvider, + type SandboxRequest, +} from '@code-zero/runner'; import type { EvidenceBundle } from '@code-zero/shared'; import { beforeEach, describe, expect, it } from 'vitest'; @@ -35,6 +41,8 @@ import { const secret = 'webhook-secret-value'; const MODE_ERROR = /mode/i; +const ISOLATION_ERROR = /isolated sandbox/; +const MODE_REFUSED_ERROR = /not allowed by the deployment settings/; function sign(body: string): string { return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; @@ -232,6 +240,146 @@ describe('runTask', () => { }); }); +describe('runTask under deployment settings', () => { + const observeOnly: DeploymentSettings = { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, allowedModes: ['observe'] }, + }; + + it('refuses a mode the settings do not allow before recording anything', async () => { + await expect( + runTask( + { repository: checkout, feedback: 'load() is wrong', mode: 'suggest' }, + { policy: { settings: observeOnly } }, + ), + ).rejects.toThrow(MODE_REFUSED_ERROR); + await expect(listTasks()).resolves.toEqual({ tasks: [] }); + }); + + it('refuses a mode an organization narrowed away even though the plan allows it', async () => { + await expect( + runTask( + { repository: checkout, feedback: 'load() is wrong', mode: 'suggest' }, + { + policy: { + settings: defaultDeploymentSettings, + organization: { + id: 'org-a', + limits: { + maxConcurrentTasks: null, + commandTimeoutMinutes: null, + allowedModes: ['observe'], + }, + }, + }, + }, + ), + ).rejects.toThrow(MODE_REFUSED_ERROR); + }); + + it('runs inside the deployment sandbox with the narrower network, whatever the repository says', async () => { + await writeFile( + join(checkout, '.code-zero.yml'), + 'version: 1\npermissions:\n network: full\nrunner:\n isolation: local\n', + 'utf8', + ); + const result = await runTask( + { repository: checkout, feedback: 'load() is wrong', mode: 'observe' }, + { + policy: { + settings: { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, maxNetwork: 'none' }, + runner: { isolation: 'container', engine: 'docker', image: 'ghcr.io/acme/runner:1' }, + }, + }, + }, + ); + + expect(result.runner).toMatchObject({ kind: 'container', isolated: true, network: 'none' }); + }); + + it('sends a hosted sandbox the network ceiling and the command timeout as its lease', async () => { + await writeFile( + join(checkout, '.code-zero.yml'), + 'version: 1\npermissions:\n network: full\n', + 'utf8', + ); + const requests: SandboxRequest[] = []; + const provider = sandboxProvider(); + const pool = new RunnerPool( + { + ...provider, + provision: (request) => { + requests.push(request); + return provider.provision(request); + }, + }, + { maxActive: 1, maxActivePerRepository: 1, maxLeaseMs: 3_600_000 }, + ); + + await runTask( + { repository: checkout, feedback: 'load() is wrong', mode: 'observe' }, + { + runnerPool: pool, + policy: { + settings: { + ...defaultDeploymentSettings, + execution: { + ...defaultDeploymentSettings.execution, + commandTimeoutMinutes: 5, + maxNetwork: 'restricted', + }, + }, + }, + }, + ); + + expect(requests).toMatchObject([{ network: 'restricted', leaseMs: 300_000 }]); + }); + + it('refuses a run that must be isolated when the hosted sandbox hands back an unisolated runner', async () => { + const provider = sandboxProvider(); + const stopped: string[] = []; + const pool = new RunnerPool( + { + ...provider, + provision: async (request) => { + const sandbox = await provider.provision(request); + const unisolated = () => ({ ...sandbox.runner.describe(), isolated: false }); + return { ...sandbox, runner: { ...sandbox.runner, describe: unisolated } }; + }, + stop: (externalId) => { + stopped.push(externalId); + return Promise.resolve(); + }, + }, + { maxActive: 1, maxActivePerRepository: 1, maxLeaseMs: 3_600_000 }, + ); + + await expect( + runTask( + { repository: checkout, feedback: 'load() is wrong', mode: 'observe' }, + { + runnerPool: pool, + policy: { settings: defaultDeploymentSettings, isolationRequired: true }, + }, + ), + ).rejects.toThrow(ISOLATION_ERROR); + expect(stopped).toHaveLength(1); + }); + + it('refuses a run that must be isolated when no sandbox exists, before recording it', async () => { + await expect( + runTask( + { repository: checkout, feedback: 'load() is wrong', mode: 'observe' }, + { policy: { settings: defaultDeploymentSettings, isolationRequired: true } }, + ), + ).rejects.toThrow(ISOLATION_ERROR); + await expect(listTasks()).resolves.toEqual({ tasks: [] }); + }); +}); + describe('ingestWebhook', () => { const options = () => ({ providers: [{ kind: 'github' as const, secret }], @@ -364,6 +512,38 @@ describe('ingestWebhook', () => { await expect(getTaskEvidence(outcome.result.id)).resolves.toContain('proactive finding'); }); + it('ignores a proactive review in a mode the deployment settings do not allow', async () => { + await writeFile( + join(checkout, '.code-zero.yml'), + 'version: 1\nproactive:\n enabled: true\nmode: suggest\n', + 'utf8', + ); + const body = JSON.stringify({ + action: 'opened', + repository: { name: 'app', owner: { login: 'acme' } }, + pull_request: { + number: 7, + base: { sha: 'b'.repeat(40) }, + head: { sha: 'a'.repeat(40) }, + }, + }); + const outcome = await ingestWebhook(githubDelivery('pull_request', body), { + ...options(), + policy: { + settings: { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, allowedModes: ['observe'] }, + }, + }, + }); + + expect(outcome).toEqual({ + status: 'ignored', + reason: 'The deployment settings do not allow suggest runs', + }); + expect(tasks.size).toBe(0); + }); + it('replays the recorded outcome for a redelivered proactive review rather than running it twice', async () => { await writeFile( join(checkout, '.code-zero.yml'), diff --git a/packages/api/src/operations.ts b/packages/api/src/operations.ts index 5893974..8621def 100644 --- a/packages/api/src/operations.ts +++ b/packages/api/src/operations.ts @@ -1,7 +1,12 @@ import { createHash } from 'node:crypto'; import { CodeZero } from '@code-zero/agent'; -import { loadConfig, mayModifyRepository } from '@code-zero/config'; +import { + applyDeploymentSettings, + isModeAllowed, + loadConfig, + mayModifyRepository, +} from '@code-zero/config'; import { isSubscriptionProviderEnabled, modelFromEnvironment } from '@code-zero/models'; import { createRunner, @@ -51,6 +56,7 @@ import { type StoredTask, type TaskStore, } from './control-plane.js'; +import { runExecution, scheduleLimits, type RunPolicy } from './settings.js'; import { claudeCodeProcessSpawner, claudeCodeRefusalReason } from './subscription-isolation.js'; const TRAILING_SLASH = /\/$/u; @@ -121,6 +127,7 @@ export async function createTask( input: z.infer, store: TaskStore = defaultStore, scheduler: TaskScheduler = defaultScheduler, + policy?: RunPolicy, ): Promise { return runTask( { @@ -131,7 +138,7 @@ export async function createTask( ...(input.source ? { source: input.source } : {}), ...(input.files ? { files: input.files } : {}), }, - { store, scheduler }, + { store, scheduler, ...(policy ? { policy } : {}) }, ); } @@ -140,6 +147,29 @@ export interface RunTaskOptions { scheduler?: TaskScheduler; /** Optional hosted execution pool; its leases still expose only the Runner boundary. */ runnerPool?: RunnerPool; + /** + * The deployment's settings this run is held to. Without one the repository's own policy + * applies alone, which is how an embedded caller with no settings has always run. + */ + policy?: RunPolicy; +} + +/** Why a run never started: its mode is one the deployment's settings do not allow. */ +function modeRefusal(mode: ReviewInput['mode']): string { + return `Execution mode '${mode}' is not allowed by the deployment settings`; +} + +/** Why a run on the managed cloud never started, or stopped before executing anything. */ +const ISOLATION_UNAVAILABLE = + 'This deployment runs repository work only inside an isolated sandbox, and none is configured for this run'; + +/** + * Whether a run that must be isolated has any sandbox to reach: the deployment's container sandbox, + * or a hosted sandbox provider's pool. Decided before anything is recorded, because without either + * the run could only execute on the service's own host. + */ +function lacksRequiredSandbox(policy: RunPolicy | undefined, runnerPool: RunnerPool | undefined) { + return policy?.isolationRequired === true && policy.settings.runner === null && !runnerPool; } /** @@ -154,6 +184,12 @@ export async function runTask( ): Promise { const store = options.store ?? defaultStore; const scheduler = options.scheduler ?? defaultScheduler; + const { policy } = options; + const execution = policy ? runExecution(policy) : undefined; + // Refused before anything is recorded: a run the settings never allowed did not happen, so it + // leaves no task behind for the board to show as failed. + if (execution && !isModeAllowed(execution, input.mode)) throw new Error(modeRefusal(input.mode)); + if (lacksRequiredSandbox(policy, options.runnerPool)) throw new Error(ISOLATION_UNAVAILABLE); const identifier = taskId(); const timestamp = now(); const record: StoredTask = { @@ -163,90 +199,107 @@ export async function runTask( createdAt: timestamp, updatedAt: timestamp, events: [], + ...(policy?.organization ? { organizationId: policy.organization.id } : {}), }; await store.save(record); try { - return await scheduler.schedule(input.repository, async () => { - record.status = 'running'; - record.updatedAt = now(); - await store.save(record); - - const config = await loadConfig(input.repository); - let eventWrites = Promise.resolve(); - const writable = mayModifyRepository(config, input.mode); - const lease = options.runnerPool - ? await options.runnerPool.acquire({ - taskId: identifier, - repository: input.repository, - mode: input.mode, - writable, - network: config.permissions.network, - leaseMs: config.agent.timeoutMs, - }) - : undefined; - const runner = - lease?.runner ?? createRunner(input.repository, runnerOptionsFromPolicy(config, writable)); - // Refuses claude-code under container isolation when no CLI container image is configured, - // and outright under a RunnerPool lease regardless of runner.isolation: SandboxProvider - // (vitehub/cloudflare/vercel/custom) returns only a bounded-command Runner, never a live - // process handle, so there is no way to route the CLI's duplex spawn through the same hosted - // boundary the lease already gives repository commands — spawning it on the control-plane - // host instead would bypass exactly the isolation, lifecycle, quota, and audit controls an - // operator configured RunnerPool for. Reported to modelFromEnvironment as a refusal reason, - // not by disabling the enable flag: the flag would also skip fallback selection, turning a - // configured CODE_ZERO_MODEL_FALLBACK_PROVIDER into a run that fails outright instead of - // degrading to it. - const refusalReason = - config.model.provider !== 'claude-code' - ? undefined - : lease - ? 'A RunnerPool lease is active for this task; the claude-code CLI process cannot be ' + - "routed through an arbitrary SandboxProvider's boundary, so it cannot honor the " + - 'isolation the lease provides for repository commands.' - : claudeCodeRefusalReason(config, process.env); - const spawnClaudeCodeProcess = - config.model.provider === 'claude-code' && - refusalReason === undefined && - isSubscriptionProviderEnabled('claude-code', process.env) - ? claudeCodeProcessSpawner(config, process.env) + return await scheduler.schedule( + input.repository, + async () => { + record.status = 'running'; + record.updatedAt = now(); + await store.save(record); + + // The repository's `.code-zero.yml` is untrusted input from the checkout; the deployment's + // settings are applied over it, stricter winning, before anything reads the result. + const repositoryConfig = await loadConfig(input.repository); + const config = policy + ? applyDeploymentSettings(repositoryConfig, policy.settings, execution) + : repositoryConfig; + let eventWrites = Promise.resolve(); + const writable = mayModifyRepository(config, input.mode); + const lease = options.runnerPool + ? await options.runnerPool.acquire({ + taskId: identifier, + repository: input.repository, + mode: input.mode, + writable, + network: config.permissions.network, + leaseMs: config.agent.timeoutMs, + }) : undefined; - const agent = new CodeZero({ - model: modelFromEnvironment( - config.model, - process.env, - spawnClaudeCodeProcess, - refusalReason, - ), - runner, - config, - taskIdentifier: identifier, - onEvent: (event) => { - record.events.push(event); - record.updatedAt = event.timestamp; - eventWrites = eventWrites.then(() => store.save(record)); - }, - }); - let result: TaskResult; - let snapshot: ChangedFileSnapshot[] | undefined; - try { - result = await agent.run(input); - // Captured through the run's own boundary before the lease is released, so publication - // later reads exactly what the run verified, never whatever the checkout holds by then. - if (result.changedFiles.length > 0) - snapshot = await snapshotChangedFiles(runner, result.changedFiles); - } finally { - if (lease) await options.runnerPool?.release(lease.id); - } - await eventWrites; - record.status = result.state; - record.updatedAt = now(); - record.result = result; - record.evidence = evidenceFromResult(result, input); - if (snapshot) record.changedFileSnapshot = snapshot; - await store.save(record); - return result; - }); + const runner = + lease?.runner ?? + createRunner(input.repository, runnerOptionsFromPolicy(config, writable)); + // A provider can hand back a runner that is not isolated; checked on what was actually built, + // and before the agent starts, so nothing executes outside the sandbox the policy requires. + if (policy?.isolationRequired && !runner.describe().isolated) { + if (lease) await options.runnerPool?.release(lease.id); + throw new Error(ISOLATION_UNAVAILABLE); + } + // Refuses claude-code under container isolation when no CLI container image is configured, + // and outright under a RunnerPool lease regardless of runner.isolation: SandboxProvider + // (vitehub/cloudflare/vercel/custom) returns only a bounded-command Runner, never a live + // process handle, so there is no way to route the CLI's duplex spawn through the same hosted + // boundary the lease already gives repository commands — spawning it on the control-plane + // host instead would bypass exactly the isolation, lifecycle, quota, and audit controls an + // operator configured RunnerPool for. Reported to modelFromEnvironment as a refusal reason, + // not by disabling the enable flag: the flag would also skip fallback selection, turning a + // configured CODE_ZERO_MODEL_FALLBACK_PROVIDER into a run that fails outright instead of + // degrading to it. + const refusalReason = + config.model.provider !== 'claude-code' + ? undefined + : lease + ? 'A RunnerPool lease is active for this task; the claude-code CLI process cannot be ' + + "routed through an arbitrary SandboxProvider's boundary, so it cannot honor the " + + 'isolation the lease provides for repository commands.' + : claudeCodeRefusalReason(config, process.env); + const spawnClaudeCodeProcess = + config.model.provider === 'claude-code' && + refusalReason === undefined && + isSubscriptionProviderEnabled('claude-code', process.env) + ? claudeCodeProcessSpawner(config, process.env) + : undefined; + const agent = new CodeZero({ + model: modelFromEnvironment( + config.model, + process.env, + spawnClaudeCodeProcess, + refusalReason, + ), + runner, + config, + taskIdentifier: identifier, + onEvent: (event) => { + record.events.push(event); + record.updatedAt = event.timestamp; + eventWrites = eventWrites.then(() => store.save(record)); + }, + }); + let result: TaskResult; + let snapshot: ChangedFileSnapshot[] | undefined; + try { + result = await agent.run(input); + // Captured through the run's own boundary before the lease is released, so publication + // later reads exactly what the run verified, never whatever the checkout holds by then. + if (result.changedFiles.length > 0) + snapshot = await snapshotChangedFiles(runner, result.changedFiles); + } finally { + if (lease) await options.runnerPool?.release(lease.id); + } + await eventWrites; + record.status = result.state; + record.updatedAt = now(); + record.result = result; + record.evidence = evidenceFromResult(result, input); + if (snapshot) record.changedFileSnapshot = snapshot; + await store.save(record); + return result; + }, + policy ? scheduleLimits(policy) : {}, + ); } catch (error) { record.status = 'failed'; record.updatedAt = now(); @@ -311,6 +364,8 @@ export interface WebhookOptions { * deduplication only spans this process's lifetime and its bounded in-memory registry. */ deliveryClaims?: DeliveryClaimStore; + /** The deployment's settings every run this delivery starts is held to. */ + policy?: RunPolicy; } export type WebhookOutcome = @@ -380,6 +435,8 @@ export async function ingestWebhook( return { status: 'ignored', reason: 'Proactive review is disabled by repository policy' }; mode = config.mode; } + const refusal = settingsRefusal(options, mode); + if (refusal) return refusal; // A proactive trigger is unrequested work started on the delivery's own say-so — the same // description that fits a poller's pass over the same commit. Both claim this key before @@ -413,6 +470,24 @@ export async function ingestWebhook( return runReviewEvent(event, mode, options); } +/** + * An outcome for a delivery whose run the deployment's settings would refuse, or null. + * + * Answered before any claim is taken, so a refused delivery leaves nothing standing for a later + * redelivery, after the settings change, to trip over. + */ +function settingsRefusal( + options: WebhookOptions, + mode: ReviewInput['mode'], +): WebhookOutcome | null { + if (!options.policy) return null; + if (!isModeAllowed(runExecution(options.policy), mode)) + return { status: 'ignored', reason: `The deployment settings do not allow ${mode} runs` }; + if (lacksRequiredSandbox(options.policy, undefined)) + return { status: 'ignored', reason: ISOLATION_UNAVAILABLE }; + return null; +} + /** * The idempotency key for one proactive review of one change-request commit. * @@ -434,6 +509,7 @@ async function runReviewEvent( const runOptions: RunTaskOptions = { ...(options.store ? { store: options.store } : {}), ...(options.scheduler ? { scheduler: options.scheduler } : {}), + ...(options.policy ? { policy: options.policy } : {}), }; const result = await runTask( reviewInputFromEvent(event, { checkoutPath: options.checkoutPath, mode }), @@ -478,6 +554,8 @@ async function ingestIssueEvent( ...(options.ignoreAuthors ? { ignoreAuthors: options.ignoreAuthors } : {}), }); if (!task) return { status: 'ignored', reason: 'No actionable issue task in this event' }; + const refusal = settingsRefusal(options, config.mode); + if (refusal) return refusal; const identity = await new LocalRunner(options.checkoutPath).originRepository(); const mismatch = checkoutRepositoryMismatch(identity, task.issue); @@ -549,6 +627,7 @@ async function runIssueTask( const runOptions: RunTaskOptions = { ...(options.store ? { store: options.store } : {}), ...(options.scheduler ? { scheduler: options.scheduler } : {}), + ...(options.policy ? { policy: options.policy } : {}), }; const result = await runTask( issueInputFromTask(task, { checkoutPath: options.checkoutPath, mode: policy.mode }), diff --git a/packages/api/src/orpc/actor.ts b/packages/api/src/orpc/actor.ts new file mode 100644 index 0000000..cb9a72f --- /dev/null +++ b/packages/api/src/orpc/actor.ts @@ -0,0 +1,14 @@ +import type { Principal } from '../access.js'; +import type { AuditActor } from '../audit.js'; + +/** + * The audited identity of an authenticated caller; never the name the request asked to use. + * + * The two authentication sources stay distinguishable in the trail: a dashboard session is a + * person, an operator token is a machine, and they are revoked through different channels. A + * record that called both `principal` would leave a reader unable to tell which one to go turn + * off — the same reason `AuditActorKind` carries `user` at all. + */ +export function principalActor(principal: Principal): AuditActor { + return { type: principal.kind === 'session' ? 'user' : 'api', id: principal.name }; +} diff --git a/packages/api/src/orpc/auth.ts b/packages/api/src/orpc/auth.ts index 416aeff..3f23867 100644 --- a/packages/api/src/orpc/auth.ts +++ b/packages/api/src/orpc/auth.ts @@ -43,7 +43,8 @@ export interface BetterAuthSessionApi { * below never looks at. */ interface BetterAuthSessionPayload { - session: Pick; + /** `activeOrganizationId` is the organization plugin's field, absent while it is off. */ + session: Pick & { readonly activeOrganizationId?: string | null }; /** `role` is the admin plugin's field, so it is absent from Better Auth's base `User`. */ user: Pick & { readonly role?: unknown }; } @@ -148,11 +149,14 @@ export async function betterAuthPrincipal( if (!auth || !reqHeaders) return undefined; const sessionData = await auth.api.getSession({ headers: reqHeaders }); if (!sessionData?.session || !sessionData.user) return undefined; - const { user } = sessionData; + const { session, user } = sessionData; // The email is the identity an operator recognises in an audit trail; the immutable user id is // the fallback so a deployment that leaves it blank still records something resolvable. const name = user.email.trim() === '' ? user.id : user.email; - return sessionPrincipal(name, await isAdministrator(auth, user)); + return sessionPrincipal(name, await isAdministrator(auth, user), { + userId: user.id, + activeOrganizationId: session.activeOrganizationId ?? null, + }); } /** @@ -173,7 +177,11 @@ async function apiKeyPrincipalFrom( if (!valid || !key) return null; // The key id is what an organization sees in its key list and revokes by, and the reference // is the organization that owns it, so the trail names something an operator can act on. - return apiKeyPrincipal(`api-key:${key.id}@${key.referenceId}`, key.permissions); + return apiKeyPrincipal( + `api-key:${key.id}@${key.referenceId}`, + key.permissions, + key.referenceId, + ); } catch { return null; } diff --git a/packages/api/src/orpc/router.ts b/packages/api/src/orpc/router.ts index dc66b53..321353a 100644 --- a/packages/api/src/orpc/router.ts +++ b/packages/api/src/orpc/router.ts @@ -11,8 +11,7 @@ import { openapi } from '@orpc/openapi'; import { ORPCError, os } from '@orpc/server'; import { z } from 'zod'; -import type { Principal } from '../access.js'; -import type { AuditActor, AuditLogStore } from '../audit.js'; +import type { AuditLogStore } from '../audit.js'; import type { TaskStore } from '../control-plane.js'; import { dashboardOverview } from '../dashboard.js'; import { @@ -25,8 +24,21 @@ import { taskInput, } from '../operations.js'; import type { RepositoryAdmin } from '../repositories.js'; +import type { DeploymentSettingsAuthority } from '../settings.js'; +import { principalActor } from './actor.js'; import { authMiddleware, type BetterAuthContext } from './auth.js'; import { useLogger } from './logging.js'; +import { + deploymentSettingsInput, + exportSettings, + organizationInput, + organizationLimitsInput, + readOrganizationSettings, + readSettings, + taskPolicy, + updateOrganizationSettings, + updateSettings, +} from './settings.js'; export interface RpcContext extends BetterAuthContext { store: TaskStore; @@ -58,6 +70,13 @@ export interface RpcContext extends BetterAuthContext { * "nothing has happened". */ auditLog?: AuditLogStore; + /** + * The deployment's settings: what they are, where they come from, and — when the dashboard is + * their source — where they are saved. Every task `tasks.create` starts is held to them. + * Optional like the capabilities above: an embedded caller without settings runs every task + * under the repository's own policy, as before settings existed. + */ + settings?: DeploymentSettingsAuthority; } /** @@ -183,9 +202,15 @@ export const rpcRouter = { // anything, so returning here without a record would leave a durable task nobody // authorised in the trail. The failure is audited under the attempted action, since no // task id is in hand to point at once the call rejected. + // Read after the identity checks above, so a caller with no grant learns nothing about the + // settings; refusals inside are audited under the same action. + const policy = await taskPolicy(context, { + repository: input.repository, + mode: input.mode, + }); let task: Awaited>; try { - task = await createTask(input, context.store); + task = await createTask(input, context.store, undefined, policy); } catch (error) { useLogger().audit({ actor, @@ -325,6 +350,69 @@ export const rpcRouter = { }); }), }, + settings: { + /** + * The settings page's read model: the whole document for a self-hosted administrator, service + * status and limits for a managed-cloud customer. + */ + get: authenticated + .meta( + openapi({ + method: 'GET', + path: '/settings', + tags: ['Settings'], + summary: 'Read the deployment settings and the status of each service', + }), + ) + .handler(({ context }) => readSettings(context)), + /** Replace the settings. Only where the dashboard is their source, and only for an admin. */ + update: authenticated + .meta( + openapi({ + method: 'PUT', + path: '/settings', + tags: ['Settings'], + summary: 'Replace the deployment settings when the dashboard is their source', + }), + ) + .input(deploymentSettingsInput) + .handler(({ input, context }) => updateSettings(context, input)), + /** The settings as the YAML the deployment file would hold, with no credential in it. */ + export: authenticated + .meta( + openapi({ + method: 'GET', + path: '/settings/export', + tags: ['Settings'], + summary: 'Export the deployment settings as YAML', + }), + ) + .handler(({ context }) => exportSettings(context)), + organization: { + get: authenticated + .meta( + openapi({ + method: 'GET', + path: '/organizations/{organizationId}/settings', + tags: ['Settings'], + summary: "Read an organization's limits on the managed cloud", + }), + ) + .input(organizationInput) + .handler(({ input, context }) => readOrganizationSettings(context, input)), + update: authenticated + .meta( + openapi({ + method: 'PUT', + path: '/organizations/{organizationId}/settings', + tags: ['Settings'], + summary: "Lower an organization's limits below its plan", + }), + ) + .input(organizationLimitsInput) + .handler(({ input, context }) => updateOrganizationSettings(context, input)), + }, + }, approvals: { decide: authenticated .meta( @@ -374,16 +462,4 @@ export const rpcRouter = { }, }; -/** - * The audited identity of an authenticated caller; never the name the request asked to use. - * - * The two authentication sources stay distinguishable in the trail: a dashboard session is a - * person, an operator token is a machine, and they are revoked through different channels. A - * record that called both `principal` would leave a reader unable to tell which one to go turn - * off — the same reason `AuditActorKind` carries `user` at all. - */ -function principalActor(principal: Principal): AuditActor { - return { type: principal.kind === 'session' ? 'user' : 'api', id: principal.name }; -} - export type RpcRouter = typeof rpcRouter; diff --git a/packages/api/src/orpc/settings.test.ts b/packages/api/src/orpc/settings.test.ts new file mode 100644 index 0000000..5f35dec --- /dev/null +++ b/packages/api/src/orpc/settings.test.ts @@ -0,0 +1,612 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { defaultDeploymentSettings, type DeploymentSettings } from '@code-zero/config'; +import { createRouterClient } from '@orpc/server'; +import { createRequestLogger, initLogger, mockAudit, type MockAudit } from 'evlog'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import type { Principal } from '../access.js'; +import { MemoryTaskStore } from '../control-plane.js'; +import type { + DashboardSettingsAuthority, + DeploymentSettingsAuthority, + FileSettingsAuthority, + OrganizationMembership, + SettingsState, +} from '../settings.js'; +import { requestLoggerStorage } from './logging.js'; +import { rpcRouter } from './router.js'; + +initLogger({ silent: true }); + +const NO_ORGANIZATION_ERROR = /Select the organization/; +const ADMIN_ERROR = /admin role/; +const FILE_SOURCE_ERROR = /deployment file/; +const ISOLATION_ERROR = /isolated sandbox/; +const CLOUD_ONLY_ERROR = /managed cloud/; +const CLOUD_LOCKED_ERROR = /managed service/; +const NOT_A_MEMBER_ERROR = /not a member/; +const MODE_REFUSED_ERROR = /not allowed by the deployment settings/; +const POLICY_ROLE_ERROR = /owner or admin/; +const SETTINGS_INVALID_ERROR = /settings are invalid/; +const SESSION_ONLY_ERROR = /token or an API key/; + +const FORBIDDEN = /FORBIDDEN|not allowed|requires|not a member|Select the organization|session/i; +const INVALID = /invalid/i; +const OWNER: OrganizationMembership = { role: 'owner', managesPolicy: true }; +const MEMBER: OrganizationMembership = { role: 'member', managesPolicy: false }; + +const SERVICE_SETTINGS: DeploymentSettings = { + execution: { + maxConcurrentTasks: 10, + commandTimeoutMinutes: 60, + allowedModes: ['observe', 'suggest'], + maxNetwork: 'restricted', + }, + model: { provider: 'anthropic', name: 'service-model-name' }, + runner: { isolation: 'container', engine: 'docker', image: 'ghcr.io/service/runner:7' }, +}; + +let store: MemoryTaskStore; +let audited: MockAudit; +let checkout: string; + +beforeEach(async () => { + store = new MemoryTaskStore(); + audited = mockAudit(); + checkout = await mkdtemp(join(tmpdir(), 'code-zero-settings-')); + await writeFile(join(checkout, 'package.json'), JSON.stringify({ scripts: {} }), 'utf8'); +}); + +afterEach(() => { + audited.restore(); +}); + +/** Opens the request logger the router audits through, as a transport would. */ +function instrumented(run: () => Promise): Promise { + if (!requestLoggerStorage) return run(); + const logger = createRequestLogger(); + return requestLoggerStorage.run(logger, async () => { + try { + return await run(); + } finally { + logger.emit(); + } + }); +} + +function session( + options: { admin?: boolean; userId?: string; activeOrganizationId?: string | null } = {}, +): Principal { + return { + name: `${options.userId ?? 'usr_1'}@example.test`, + kind: 'session', + modes: options.admin ? ['observe', 'suggest', 'fix', 'autonomous'] : ['observe', 'suggest'], + admin: options.admin ?? false, + account: { + userId: options.userId ?? 'usr_1', + activeOrganizationId: options.activeOrganizationId ?? null, + }, + }; +} + +const operatorToken: Principal = { + name: 'service-ops', + kind: 'token', + modes: ['observe', 'suggest'], + admin: false, +}; + +function apiKey(organizationId: string): Principal { + return { + name: `api-key:key_1@${organizationId}`, + kind: 'apiKey', + modes: ['observe', 'suggest'], + admin: false, + organizationId, + }; +} + +/** Counts reads, so a test can show a refusal came before the source was touched. */ +interface Counted { + reads: number; +} + +function fileAuthority( + state: SettingsState = ready(defaultDeploymentSettings), +): FileSettingsAuthority & Counted { + const authority: FileSettingsAuthority & Counted = { + edition: 'self-hosted', + source: 'file', + filePath: '/srv/code-zero/config.yml', + reads: 0, + read: () => { + authority.reads += 1; + return Promise.resolve(state); + }, + services: () => + Promise.resolve([ + { service: 'model', configured: false }, + { service: 'database', configured: true }, + ]), + }; + return authority; +} + +function dashboardAuthority(): DashboardSettingsAuthority & { saved: DeploymentSettings[] } { + let current = defaultDeploymentSettings; + const authority: DashboardSettingsAuthority & { saved: DeploymentSettings[] } = { + edition: 'self-hosted', + source: 'dashboard', + saved: [], + read: () => Promise.resolve(ready(current)), + save: (settings) => { + current = settings; + authority.saved.push(settings); + return Promise.resolve(); + }, + services: () => Promise.resolve([]), + }; + return authority; +} + +/** + * A managed-cloud deployment whose memberships and stored organization limits live in memory. + * + * `members` maps an organization to the accounts in it; changing it between calls is how a test + * removes someone from an organization while their session still names it. + */ +function cloudAuthority( + members: Map>, + limits = new Map>(), + settings: DeploymentSettings = SERVICE_SETTINGS, +): FileSettingsAuthority & { savedLimits: Map> } { + return { + edition: 'cloud', + source: 'file', + filePath: '/srv/service/config.yml', + savedLimits: limits, + read: () => Promise.resolve(ready(settings)), + services: () => + Promise.resolve([ + { service: 'model', configured: true }, + { service: 'sandbox', configured: true }, + { service: 'database', configured: true }, + { service: 'mail', configured: true }, + ]), + organizations: { + membership: (organizationId, userId) => + Promise.resolve(members.get(organizationId)?.get(userId) ?? null), + read: (organizationId) => Promise.resolve(limits.get(organizationId) ?? null), + save: (organizationId, document) => { + limits.set(organizationId, document); + return Promise.resolve(); + }, + }, + }; +} + +function ready(settings: DeploymentSettings): SettingsState { + return { ok: true, settings, saved: null, warnings: [] }; +} + +function client(principal: Principal, settings: DeploymentSettingsAuthority) { + return createRouterClient(rpcRouter, { + context: { store, principal, settings, mayTargetRepository: () => true }, + }); +} + +describe('settings.get', () => { + it('refuses a self-hosted member who is not an administrator before reading the source', async () => { + const authority = fileAuthority(); + + await expect(instrumented(() => client(session(), authority).settings.get())).rejects.toThrow( + ADMIN_ERROR, + ); + expect(authority.reads).toBe(0); + }); + + it('refuses an operator token and an organization API key', async () => { + const authority = fileAuthority(); + + await expect( + instrumented(() => client(operatorToken, authority).settings.get()), + ).rejects.toThrow(FORBIDDEN); + await expect( + instrumented(() => client(apiKey('org-a'), authority).settings.get()), + ).rejects.toThrow(FORBIDDEN); + }); + + it('shows a self-hosted administrator the file as a read-only source', async () => { + const view = await instrumented(() => + client(session({ admin: true }), fileAuthority()).settings.get(), + ); + + expect(view).toMatchObject({ + edition: 'self-hosted', + source: 'file', + editable: false, + filePath: '/srv/code-zero/config.yml', + }); + }); + + it('shows a cloud customer whether services work, never how the service configured them', async () => { + const members = new Map([['org-a', new Map([['usr_1', MEMBER]])]]); + const view = await instrumented(() => + client(session({ activeOrganizationId: 'org-a' }), cloudAuthority(members)).settings.get(), + ); + const published = JSON.stringify(view); + + expect(view).toMatchObject({ + edition: 'cloud', + organization: { id: 'org-a', editable: false }, + }); + expect(published).not.toContain('service-model-name'); + expect(published).not.toContain('ghcr.io/service/runner'); + expect(published).not.toContain('/srv/service/config.yml'); + }); +}); + +describe('settings.update', () => { + const input = { + execution: { + maxConcurrentTasks: 2, + commandTimeoutMinutes: 15, + allowedModes: ['observe'], + maxNetwork: 'none', + }, + model: null, + runner: null, + }; + + it('saves the settings when the dashboard is their source', async () => { + const authority = dashboardAuthority(); + + await instrumented(() => client(session({ admin: true }), authority).settings.update(input)); + + expect(authority.saved).toEqual([ + { + execution: { + maxConcurrentTasks: 2, + commandTimeoutMinutes: 15, + allowedModes: ['observe'], + maxNetwork: 'none', + }, + model: null, + runner: null, + }, + ]); + expect(audited.events).toMatchObject([{ action: 'settings.updated', outcome: 'success' }]); + }); + + it('refuses an invalid document whole and saves nothing', async () => { + const authority = dashboardAuthority(); + + await expect( + instrumented(() => + client(session({ admin: true }), authority).settings.update({ + ...input, + execution: { ...input.execution, maxConcurrentTasks: 0 }, + }), + ), + ).rejects.toThrow(INVALID); + expect(authority.saved).toEqual([]); + }); + + it('refuses edits while the file is the source, even from an administrator', async () => { + await expect( + instrumented(() => client(session({ admin: true }), fileAuthority()).settings.update(input)), + ).rejects.toThrow(FILE_SOURCE_ERROR); + expect(audited.events).toMatchObject([{ action: 'settings.update', outcome: 'denied' }]); + }); + + it('refuses every edit on the managed cloud, even from an administrator', async () => { + const members = new Map([['org-a', new Map([['usr_1', OWNER]])]]); + + await expect( + instrumented(() => + client( + session({ admin: true, activeOrganizationId: 'org-a' }), + cloudAuthority(members), + ).settings.update(input), + ), + ).rejects.toThrow(CLOUD_LOCKED_ERROR); + expect(audited.events).toMatchObject([{ action: 'settings.update', outcome: 'denied' }]); + }); + + it('refuses an organization API key, whatever it is allowed to run', async () => { + const authority = dashboardAuthority(); + + await expect( + instrumented(() => client(apiKey('org-a'), authority).settings.update(input)), + ).rejects.toThrow(SESSION_ONLY_ERROR); + expect(authority.saved).toEqual([]); + expect(audited.events).toMatchObject([ + { actor: { type: 'api' }, action: 'settings.update', outcome: 'denied' }, + ]); + }); + + it('refuses a signed-in member who is not an administrator', async () => { + const authority = dashboardAuthority(); + + await expect( + instrumented(() => client(session(), authority).settings.update(input)), + ).rejects.toThrow(ADMIN_ERROR); + expect(authority.saved).toEqual([]); + }); +}); + +describe('settings.export', () => { + it('exports YAML that carries no credential from the environment', async () => { + const secret = 'gateway-secret-0123456789'; + const original = process.env.AI_GATEWAY_API_KEY; + process.env.AI_GATEWAY_API_KEY = secret; + try { + const { yaml } = await instrumented(() => + client( + session({ admin: true }), + fileAuthority( + ready({ + ...defaultDeploymentSettings, + model: { provider: 'ai-gateway', name: `${secret}-model` }, + }), + ), + ).settings.export(), + ); + + expect(yaml).toContain('provider: ai-gateway'); + expect(yaml).not.toContain(secret); + } finally { + if (original === undefined) delete process.env.AI_GATEWAY_API_KEY; + else process.env.AI_GATEWAY_API_KEY = original; + } + }); + + it('refuses to export the managed service settings to a customer', async () => { + await expect( + instrumented(() => + client(session({ admin: true }), cloudAuthority(new Map())).settings.export(), + ), + ).rejects.toThrow(CLOUD_LOCKED_ERROR); + }); +}); + +describe('settings.organization', () => { + const lower = { + organizationId: 'org-a', + limits: { maxConcurrentTasks: 2, commandTimeoutMinutes: null, allowedModes: ['observe'] }, + }; + + it('lets an owner lower the limits below the plan', async () => { + const members = new Map([['org-a', new Map([['usr_1', OWNER]])]]); + + const view = await instrumented(() => + client( + session({ activeOrganizationId: 'org-a' }), + cloudAuthority(members), + ).settings.organization.update(lower), + ); + + expect(view.effective).toMatchObject({ + maxConcurrentTasks: 2, + commandTimeoutMinutes: 60, + allowedModes: ['observe'], + }); + expect(audited.events).toMatchObject([ + { action: 'organization.settings.updated', target: { type: 'organization', id: 'org-a' } }, + ]); + }); + + it('refuses limits above the plan, naming each one', async () => { + const members = new Map([['org-a', new Map([['usr_1', OWNER]])]]); + const authority = cloudAuthority(members); + + await expect( + instrumented(() => + client(session(), authority).settings.organization.update({ + organizationId: 'org-a', + limits: { maxConcurrentTasks: 11, commandTimeoutMinutes: 61, allowedModes: null }, + }), + ), + ).rejects.toThrow(INVALID); + expect(authority.savedLimits.size).toBe(0); + }); + + it('refuses a member whose role does not manage the policy', async () => { + const members = new Map([['org-a', new Map([['usr_1', MEMBER]])]]); + const authority = cloudAuthority(members); + + await expect( + instrumented(() => client(session(), authority).settings.organization.update(lower)), + ).rejects.toThrow(POLICY_ROLE_ERROR); + expect(authority.savedLimits.size).toBe(0); + expect(audited.events).toMatchObject([ + { action: 'organization.settings.update', outcome: 'denied' }, + ]); + }); + + it("refuses to read or change another organization's limits", async () => { + const members = new Map([ + ['org-a', new Map([['usr_1', OWNER]])], + ['org-b', new Map([['usr_2', OWNER]])], + ]); + const authority = cloudAuthority(members); + const intruder = client(session({ activeOrganizationId: 'org-a' }), authority); + + await expect( + instrumented(() => intruder.settings.organization.get({ organizationId: 'org-b' })), + ).rejects.toThrow(NOT_A_MEMBER_ERROR); + await expect( + instrumented(() => + intruder.settings.organization.update({ ...lower, organizationId: 'org-b' }), + ), + ).rejects.toThrow(NOT_A_MEMBER_ERROR); + expect(authority.savedLimits.size).toBe(0); + }); + + it('does not exist on a self-hosted deployment', async () => { + await expect( + instrumented(() => + client(session({ admin: true }), dashboardAuthority()).settings.organization.update(lower), + ), + ).rejects.toThrow(CLOUD_ONLY_ERROR); + }); +}); + +describe('tasks.create under the deployment settings', () => { + it('refuses every task while the settings are invalid', async () => { + const authority = fileAuthority({ + ok: false, + issues: [{ path: '$.execution.max_concurrent_tasks', message: 'Expected an integer.' }], + }); + + await expect( + instrumented(() => + client(session({ admin: true }), authority).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'observe', + }), + ), + ).rejects.toThrow(SETTINGS_INVALID_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + + it('refuses a mode the settings no longer allow, even to an administrator', async () => { + const authority = fileAuthority( + ready({ + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, allowedModes: ['observe'] }, + }), + ); + + await expect( + instrumented(() => + client(session({ admin: true }), authority).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'fix', + }), + ), + ).rejects.toThrow(MODE_REFUSED_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + + describe('on the managed cloud', () => { + const members = () => + new Map([ + ['org-a', new Map([['usr_1', MEMBER]])], + ['org-b', new Map([['usr_2', MEMBER]])], + ]); + + it('refuses a session that selected no organization', async () => { + await expect( + instrumented(() => + client(session(), cloudAuthority(members())).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'observe', + }), + ), + ).rejects.toThrow(NO_ORGANIZATION_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + + it('refuses a session whose membership in its selected organization was removed', async () => { + const current = members(); + current.get('org-a')?.delete('usr_1'); + + await expect( + instrumented(() => + client(session({ activeOrganizationId: 'org-a' }), cloudAuthority(current)).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'observe', + }), + ), + ).rejects.toThrow(NOT_A_MEMBER_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + + it("refuses a session that selected another customer's organization", async () => { + await expect( + instrumented(() => + client( + session({ activeOrganizationId: 'org-b' }), + cloudAuthority(members()), + ).tasks.create({ repository: checkout, feedback: 'x', mode: 'observe' }), + ), + ).rejects.toThrow(NOT_A_MEMBER_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + + it("holds an organization API key to its own organization's narrower modes", async () => { + const limits = new Map([['org-a', { allowed_modes: ['observe'] }]]); + + await expect( + instrumented(() => + client(apiKey('org-a'), cloudAuthority(members(), limits)).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'suggest', + }), + ), + ).rejects.toThrow(MODE_REFUSED_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + + it('records the organization a task was created for, whatever the session selects later', async () => { + const current = members(); + current.get('org-b')?.set('usr_1', MEMBER); + const authority = cloudAuthority(current); + + const first = await instrumented(() => + client(session({ activeOrganizationId: 'org-a' }), authority).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'observe', + }), + ); + const second = await instrumented(() => + client(session({ activeOrganizationId: 'org-b' }), authority).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'observe', + }), + ); + + await expect(store.get(first.id)).resolves.toMatchObject({ organizationId: 'org-a' }); + await expect(store.get(second.id)).resolves.toMatchObject({ organizationId: 'org-b' }); + }); + + it('runs an operator token for the service under the plan, bound to no organization', async () => { + const task = await instrumented(() => + client(operatorToken, cloudAuthority(members())).tasks.create({ + repository: checkout, + feedback: 'x', + mode: 'observe', + }), + ); + + const record = await store.get(task.id); + expect(record).toBeDefined(); + expect(record?.organizationId).toBeUndefined(); + }); + + it('refuses a customer task when the service configured no sandbox to run it in', async () => { + const onlyOrgA = new Map([['org-a', new Map([['usr_1', MEMBER]])]]); + + await expect( + instrumented(() => + client( + session({ activeOrganizationId: 'org-a' }), + cloudAuthority(onlyOrgA, new Map(), { ...SERVICE_SETTINGS, runner: null }), + ).tasks.create({ repository: checkout, feedback: 'x', mode: 'observe' }), + ), + ).rejects.toThrow(ISOLATION_ERROR); + await expect(store.list()).resolves.toEqual([]); + }); + }); +}); diff --git a/packages/api/src/orpc/settings.ts b/packages/api/src/orpc/settings.ts new file mode 100644 index 0000000..bfbd35b --- /dev/null +++ b/packages/api/src/orpc/settings.ts @@ -0,0 +1,421 @@ +import { + EXECUTION_LIMITS, + effectiveExecution, + isModeAllowed, + noOrganizationLimits, + organizationLimitsAbovePlan, + organizationLimitsDocument, + parseDeploymentSettings, + parseOrganizationLimits, + renderDeploymentSettings, + type DeploymentConfigIssue, + type ExecutionSettings, + type OrganizationLimits, +} from '@code-zero/config'; +import { ORPCError } from '@orpc/server'; +import { z } from 'zod'; + +import type { Principal } from '../access.js'; +import { + runExecution, + type DeploymentSettingsAuthority, + type OrganizationSettingsStore, + type RunPolicy, + type ServiceStatus, + type SettingsState, +} from '../settings.js'; +import { principalActor } from './actor.js'; +import { useLogger } from './logging.js'; + +/** What every settings handler reads from the procedure context. */ +interface SettingsContext { + principal: Principal; + settings?: DeploymentSettingsAuthority | undefined; +} + +/** + * `settings.update`'s input: the shape of the settings, and nothing about their values. + * + * Values are checked once, by `@code-zero/config`'s parser, against the same rules the deployment + * file is held to — a second copy of the bounds here is how the form and the file would come to + * disagree about what is allowed. + */ +export const deploymentSettingsInput = z.object({ + execution: z.object({ + maxConcurrentTasks: z.number(), + commandTimeoutMinutes: z.number(), + allowedModes: z.array(z.string()), + maxNetwork: z.string(), + }), + model: z.object({ provider: z.string(), name: z.string() }).nullable(), + runner: z.object({ isolation: z.string(), engine: z.string(), image: z.string() }).nullable(), +}); + +/** `settings.organization.get`'s input. The organization is named, then membership is checked. */ +export const organizationInput = z.object({ organizationId: z.string().min(1) }); + +/** `settings.organization.update`'s input: `null` keeps the plan's value. */ +export const organizationLimitsInput = organizationInput.extend({ + limits: z.object({ + maxConcurrentTasks: z.number().nullable(), + commandTimeoutMinutes: z.number().nullable(), + allowedModes: z.array(z.string()).nullable(), + }), +}); + +/** One organization's limits, as a member sees them. */ +export interface OrganizationSettingsView { + id: string; + role: string; + /** Whether this member may change the limits. The update procedure checks again regardless. */ + editable: boolean; + limits: OrganizationLimits; + /** True when the stored limits could not be read; runs for the organization are refused. */ + invalid: boolean; + /** The limits its runs are held to, or null while the service settings are unavailable. */ + effective: ExecutionSettings | null; +} + +/** + * The settings page's read model. + * + * Self-hosted, an administrator sees the whole document and where it comes from. On the managed + * cloud a customer sees whether each service works, the plan's limits, and their organization's + * own — never the service's model, sandbox, or connections, and nothing that could carry a secret. + */ +export type SettingsView = + | { + edition: 'self-hosted'; + source: 'file' | 'dashboard'; + /** Whether settings can be saved from the dashboard at all. */ + editable: boolean; + filePath: string | null; + state: SettingsState; + services: readonly ServiceStatus[]; + bounds: typeof EXECUTION_LIMITS; + } + | { + edition: 'cloud'; + services: readonly ServiceStatus[]; + /** The plan's limits, or null while the service settings are unavailable. */ + plan: ExecutionSettings | null; + organization: OrganizationSettingsView | null; + bounds: typeof EXECUTION_LIMITS; + }; + +const SESSION_ONLY = + 'Settings are managed from a signed-in dashboard session, not with a token or an API key'; +const ADMIN_ONLY = 'Managing the deployment settings requires the admin role'; +const CLOUD_LOCKED = 'The managed service configures these settings; they cannot be changed here'; +const FILE_SOURCE = + 'These settings come from the deployment file; edit the file, or make the dashboard their source'; +const NOT_A_MEMBER = 'You are not a member of this organization'; +const NOT_A_POLICY_MANAGER = "Changing this organization's limits requires the owner or admin role"; +const SETTINGS_INVALID = + 'The deployment settings are invalid, so no task can start until they are fixed'; +const ORGANIZATION_LIMITS_INVALID = + "This organization's stored limits are invalid, so its tasks cannot start until they are saved again"; +const CLOUD_ONLY = 'Organization limits exist on the managed cloud only'; +const NO_ORGANIZATION_SELECTED = 'Select the organization this task is for before starting it'; +const KEY_WITHOUT_ORGANIZATION = 'This API key does not belong to an organization'; +const ORGANIZATION_UNVERIFIABLE = + 'Organization membership cannot be verified on this deployment, so no customer task can start'; + +/** `settings.get`. Authorizes before it reads anything from the source or the services. */ +export async function readSettings(context: SettingsContext): Promise { + const authority = requireAuthority(context.settings); + const { principal } = context; + if (principal.kind !== 'session') throw new ORPCError('FORBIDDEN', { message: SESSION_ONLY }); + if (authority.edition === 'self-hosted') { + if (!principal.admin) throw new ORPCError('FORBIDDEN', { message: ADMIN_ONLY }); + const [state, services] = await Promise.all([authority.read(), authority.services()]); + return { + edition: 'self-hosted', + source: authority.source, + editable: authority.source === 'dashboard', + filePath: authority.source === 'file' ? authority.filePath : null, + state, + services, + bounds: EXECUTION_LIMITS, + }; + } + const [state, services] = await Promise.all([authority.read(), authority.services()]); + // A customer is told whether the plan resolved, never why not: the reasons name the service's + // own file and values. + const plan = state.ok ? state.settings.execution : null; + const organizationId = principal.account?.activeOrganizationId ?? null; + const organization = + organizationId && authority.organizations + ? await organizationView(authority.organizations, principal, organizationId, plan) + : null; + return { edition: 'cloud', services, plan, organization, bounds: EXECUTION_LIMITS }; +} + +/** `settings.update`: replace the deployment settings, when the dashboard is their source. */ +export async function updateSettings( + context: SettingsContext, + input: z.infer, +): Promise { + const authority = requireAuthority(context.settings); + const { principal } = context; + const target = { type: 'settings', id: 'deployment' }; + // Every refusal below is decided here, on the server, whatever the page rendered: a disabled + // input is a courtesy, not a control. + if (principal.kind !== 'session') refuse(principal, 'settings.update', target, SESSION_ONLY); + if (authority.edition === 'cloud') refuse(principal, 'settings.update', target, CLOUD_LOCKED); + if (!principal.admin) refuse(principal, 'settings.update', target, ADMIN_ONLY); + if (authority.source !== 'dashboard') refuse(principal, 'settings.update', target, FILE_SOURCE); + + const parsed = parseDeploymentSettings(documentFromInput(input)); + if (!parsed.ok) throw invalid(parsed.issues); + await authority.save(parsed.value, principal.name); + useLogger().audit({ + actor: principalActor(principal), + action: 'settings.updated', + outcome: 'success', + target, + }); + return readSettings(context); +} + +/** `settings.export`: the settings as the YAML the deployment file would hold. */ +export async function exportSettings(context: SettingsContext): Promise<{ yaml: string }> { + const authority = requireAuthority(context.settings); + if (context.principal.kind !== 'session') + throw new ORPCError('FORBIDDEN', { message: SESSION_ONLY }); + if (authority.edition === 'cloud') throw new ORPCError('FORBIDDEN', { message: CLOUD_LOCKED }); + if (!context.principal.admin) throw new ORPCError('FORBIDDEN', { message: ADMIN_ONLY }); + const state = await authority.read(); + if (!state.ok) throw new ORPCError('PRECONDITION_FAILED', { message: SETTINGS_INVALID }); + return { yaml: renderDeploymentSettings(state.settings) }; +} + +/** `settings.organization.get`: one organization's limits, for one of its members. */ +export async function readOrganizationSettings( + context: SettingsContext, + input: z.infer, +): Promise { + const { authority, organizations } = requireOrganizations(context.settings); + if (context.principal.kind !== 'session') + throw new ORPCError('FORBIDDEN', { message: SESSION_ONLY }); + const state = await authority.read(); + const view = await organizationView( + organizations, + context.principal, + input.organizationId, + state.ok ? state.settings.execution : null, + ); + if (!view) throw new ORPCError('FORBIDDEN', { message: NOT_A_MEMBER }); + return view; +} + +/** `settings.organization.update`: lower an organization's limits below the plan. */ +export async function updateOrganizationSettings( + context: SettingsContext, + input: z.infer, +): Promise { + const { authority, organizations } = requireOrganizations(context.settings); + const { principal } = context; + const target = { type: 'organization', id: input.organizationId }; + if (principal.kind !== 'session' || !principal.account) + refuse(principal, 'organization.settings.update', target, SESSION_ONLY); + const membership = await organizations.membership(input.organizationId, principal.account.userId); + // One answer for "no such organization" and "not yours", so the refusal does not reveal which + // organizations exist. + if (!membership) refuse(principal, 'organization.settings.update', target, NOT_A_MEMBER); + if (!membership.managesPolicy) + refuse(principal, 'organization.settings.update', target, NOT_A_POLICY_MANAGER); + + const state = await authority.read(); + if (!state.ok) throw new ORPCError('PRECONDITION_FAILED', { message: SETTINGS_INVALID }); + const parsed = parseOrganizationLimits({ + max_concurrent_tasks: input.limits.maxConcurrentTasks, + command_timeout_minutes: input.limits.commandTimeoutMinutes, + allowed_modes: input.limits.allowedModes, + }); + if (!parsed.ok) throw invalid(parsed.issues); + const abovePlan = organizationLimitsAbovePlan(parsed.value, state.settings.execution); + if (abovePlan.length > 0) throw invalid(abovePlan); + + await organizations.save( + input.organizationId, + organizationLimitsDocument(parsed.value), + principal.name, + ); + useLogger().audit({ + actor: principalActor(principal), + action: 'organization.settings.updated', + outcome: 'success', + target, + }); + const view = await organizationView( + organizations, + principal, + input.organizationId, + state.settings.execution, + ); + if (!view) throw new ORPCError('FORBIDDEN', { message: NOT_A_MEMBER }); + return view; +} + +/** + * The policy a task this principal creates runs under, or a refusal. + * + * `undefined` when the deployment keeps no settings at all, which is an embedded caller running + * under repository policy alone, as it always has. Invalid settings refuse the task rather than + * running it on defaults nobody chose, and so do an organization's unreadable limits. + */ +export async function taskPolicy( + context: SettingsContext, + request: { repository: string; mode: Principal['modes'][number] }, +): Promise { + const authority = context.settings; + if (!authority) return undefined; + const { principal } = context; + const target = { type: 'repository', id: request.repository, mode: request.mode }; + const state = await authority.read(); + if (!state.ok) refuse(principal, 'task.create', target, SETTINGS_INVALID, 'PRECONDITION_FAILED'); + + const owner = await runOwner(authority, principal); + if (owner.kind === 'refused') refuse(principal, 'task.create', target, owner.reason); + let organization: RunPolicy['organization']; + if (owner.kind === 'organization') { + const limits = parseOrganizationLimits(await owner.store.read(owner.id)); + if (!limits.ok) + refuse(principal, 'task.create', target, ORGANIZATION_LIMITS_INVALID, 'PRECONDITION_FAILED'); + organization = { id: owner.id, limits: limits.value }; + } + const policy: RunPolicy = { + settings: state.settings, + ...(organization ? { organization } : {}), + isolationRequired: authority.edition === 'cloud', + }; + if (!isModeAllowed(runExecution(policy), request.mode)) + refuse( + principal, + 'task.create', + target, + `Execution mode '${request.mode}' is not allowed by the deployment settings`, + ); + return policy; +} + +/** Who a run is started for, decided once, when it is created. */ +type RunOwner = + | { kind: 'deployment' } + | { kind: 'organization'; id: string; store: OrganizationSettingsStore } + | { kind: 'refused'; reason: string }; + +/** + * Who a run is started for. + * + * Self-hosted, every run is the deployment's own. On the managed cloud a customer's run always + * belongs to one verified organization: an organization API key to the organization Better Auth + * bound it to, and a session to the organization it selected, once the member table confirms the + * account still belongs there. A session with no organization selected, or one that selected an + * organization it is not (or no longer) a member of, is refused rather than run under the plan + * alone — falling back would let a customer shed their organization's stricter limits by clearing + * or switching the selection. + * + * Operator tokens are the one deliberate exception: they are the service's own credentials, set in + * its environment by its operators, so they act for the deployment under the plan and never for a + * customer organization. + */ +async function runOwner( + authority: DeploymentSettingsAuthority, + principal: Principal, +): Promise { + if (authority.edition !== 'cloud' || principal.kind === 'token') return { kind: 'deployment' }; + const store = authority.organizations; + if (!store) return { kind: 'refused', reason: ORGANIZATION_UNVERIFIABLE }; + if (principal.kind === 'apiKey') + return principal.organizationId + ? { kind: 'organization', id: principal.organizationId, store } + : { kind: 'refused', reason: KEY_WITHOUT_ORGANIZATION }; + const account = principal.account; + if (!account?.activeOrganizationId) return { kind: 'refused', reason: NO_ORGANIZATION_SELECTED }; + const membership = await store.membership(account.activeOrganizationId, account.userId); + return membership + ? { kind: 'organization', id: account.activeOrganizationId, store } + : { kind: 'refused', reason: NOT_A_MEMBER }; +} + +async function organizationView( + store: OrganizationSettingsStore, + principal: Principal, + organizationId: string, + plan: ExecutionSettings | null, +): Promise { + if (!principal.account) return null; + const membership = await store.membership(organizationId, principal.account.userId); + if (!membership) return null; + const parsed = parseOrganizationLimits(await store.read(organizationId)); + const limits = parsed.ok ? parsed.value : noOrganizationLimits; + return { + id: organizationId, + role: membership.role, + editable: membership.managesPolicy, + limits, + invalid: !parsed.ok, + effective: plan && parsed.ok ? effectiveExecution(plan, limits) : null, + }; +} + +/** The deployment-file document a settings form describes, ready for the one strict parser. */ +function documentFromInput( + input: z.infer, +): Record { + return { + execution: { + max_concurrent_tasks: input.execution.maxConcurrentTasks, + command_timeout_minutes: input.execution.commandTimeoutMinutes, + allowed_modes: input.execution.allowedModes, + max_network: input.execution.maxNetwork, + }, + ...(input.model ? { model: { ...input.model } } : {}), + ...(input.runner ? { runner: { ...input.runner } } : {}), + }; +} + +function requireAuthority( + settings: DeploymentSettingsAuthority | undefined, +): DeploymentSettingsAuthority { + if (!settings) + throw new ORPCError('NOT_IMPLEMENTED', { message: 'This deployment keeps no settings' }); + return settings; +} + +function requireOrganizations(settings: DeploymentSettingsAuthority | undefined): { + authority: DeploymentSettingsAuthority; + organizations: OrganizationSettingsStore; +} { + const authority = requireAuthority(settings); + if (authority.edition !== 'cloud' || !authority.organizations) + throw new ORPCError('FORBIDDEN', { message: CLOUD_ONLY }); + return { authority, organizations: authority.organizations }; +} + +/** A settings document the parser refused, with every issue for the form to point at. */ +function invalid(issues: readonly DeploymentConfigIssue[]): ORPCError<'BAD_REQUEST', unknown> { + return new ORPCError('BAD_REQUEST', { + message: 'The settings are invalid', + data: { issues: issues.map((issue) => ({ path: issue.path, message: issue.message })) }, + }); +} + +/** + * Refuse, and leave the refusal in the audit trail. + * + * Refusals are recorded as deliberately as grants: a key or an account repeatedly reaching for + * settings it was never given is the signal a trail exists to preserve. + */ +function refuse( + principal: Principal, + action: string, + target: Record & { type: string; id: string }, + message: string, + code: 'FORBIDDEN' | 'PRECONDITION_FAILED' = 'FORBIDDEN', +): never { + useLogger().audit.deny(message, { actor: principalActor(principal), action, target }); + throw new ORPCError(code, { message }); +} diff --git a/packages/api/src/settings.test.ts b/packages/api/src/settings.test.ts new file mode 100644 index 0000000..a780694 --- /dev/null +++ b/packages/api/src/settings.test.ts @@ -0,0 +1,78 @@ +import { defaultDeploymentSettings } from '@code-zero/config'; +import { describe, expect, it } from 'vitest'; + +import { deploymentServices, runExecution, scheduleLimits } from './settings.js'; + +const connections = { database: true, mail: false, hostedSandbox: false }; + +describe('deploymentServices', () => { + it('reports whether the named model has a credential, never the credential', () => { + const secret = 'sk-ant-0123456789abcdefghij'; + const services = deploymentServices( + { ...defaultDeploymentSettings, model: { provider: 'anthropic', name: 'claude-sonnet-4-5' } }, + connections, + { ANTHROPIC_API_KEY: secret }, + ); + + expect(services).toContainEqual({ service: 'model', configured: true }); + expect(JSON.stringify(services)).not.toContain(secret); + }); + + it('says nothing about the model while each repository chooses its own', () => { + const services = deploymentServices(defaultDeploymentSettings, connections, {}); + + expect(services.map((status) => status.service)).not.toContain('model'); + }); + + it('counts a hosted sandbox provider as the sandbox without a container image', () => { + expect( + deploymentServices(defaultDeploymentSettings, { ...connections, hostedSandbox: true }, {}), + ).toContainEqual({ service: 'sandbox', configured: true }); + }); + + it('reports no sandbox when neither a container nor a hosted provider is set up', () => { + expect(deploymentServices(defaultDeploymentSettings, connections, {})).toContainEqual({ + service: 'sandbox', + configured: false, + }); + }); +}); + +describe('scheduleLimits', () => { + it("bounds an organization's runs by its own lower limit and everyone by the plan's", () => { + expect( + scheduleLimits({ + settings: defaultDeploymentSettings, + organization: { + id: 'org-a', + limits: { maxConcurrentTasks: 1, commandTimeoutMinutes: null, allowedModes: null }, + }, + }), + ).toEqual({ maxConcurrent: 4, group: { key: 'org-a', maxConcurrent: 1 } }); + }); + + it('leaves work started for the deployment itself to the plan alone', () => { + expect(scheduleLimits({ settings: defaultDeploymentSettings })).toEqual({ maxConcurrent: 4 }); + }); +}); + +describe('runExecution', () => { + it('never lets an organization widen what the plan allows', () => { + expect( + runExecution({ + settings: { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, allowedModes: ['observe'] }, + }, + organization: { + id: 'org-a', + limits: { + maxConcurrentTasks: 64, + commandTimeoutMinutes: null, + allowedModes: ['observe', 'autonomous'], + }, + }, + }), + ).toMatchObject({ maxConcurrentTasks: 4, allowedModes: ['observe'] }); + }); +}); diff --git a/packages/api/src/settings.ts b/packages/api/src/settings.ts new file mode 100644 index 0000000..c94e3a0 --- /dev/null +++ b/packages/api/src/settings.ts @@ -0,0 +1,170 @@ +import { + effectiveExecution, + type DeploymentConfigIssue, + type DeploymentSettings, + type Edition, + type ExecutionSettings, + type OrganizationLimits, +} from '@code-zero/config'; +import { isModelConfigured } from '@code-zero/models'; + +import type { ScheduleLimits } from './control-plane.js'; + +/** What the deployment's settings resolved to when they were read. */ +export type SettingsState = + | { + readonly ok: true; + readonly settings: DeploymentSettings; + /** Who saved the stored settings, and when. Null for the file source and for defaults. */ + readonly saved: { readonly by: string; readonly at: string } | null; + /** + * Problems that leave the settings usable: a deployment file that states settings while the + * dashboard is their source, for one. Shown, never acted on. + */ + readonly warnings: readonly DeploymentConfigIssue[]; + } + | { readonly ok: false; readonly issues: readonly DeploymentConfigIssue[] }; + +/** A service a deployment depends on. */ +export type ServiceName = 'model' | 'sandbox' | 'database' | 'mail'; + +/** + * Whether one service is set up, and nothing more. + * + * No credential, URL, host, or provider name: on the managed cloud these are the service's own + * secrets, and a customer is owed whether the service works, not how it is wired. + */ +export interface ServiceStatus { + readonly service: ServiceName; + readonly configured: boolean; +} + +/** An account's membership in one organization, as the store holds it right now. */ +export interface OrganizationMembership { + readonly role: string; + /** Whether that role may change the organization's policy. */ + readonly managesPolicy: boolean; +} + +/** + * Organization limits on the managed cloud, backed by the composition root's store. + * + * Membership is a lookup rather than something carried on the session, because a session remembers + * the organization it last acted in, not whether its account still belongs there. + */ +export interface OrganizationSettingsStore { + membership(organizationId: string, userId: string): Promise; + /** The stored limits document, or null when the organization never chose any. */ + read(organizationId: string): Promise; + save(organizationId: string, document: Record, actor: string): Promise; +} + +interface SettingsAuthorityBase { + readonly edition: Edition; + /** Reads the source again on every call, so a saved change is what the next run is held to. */ + read(): Promise; + services(): Promise; + /** Present only on the managed cloud, where organizations may choose stricter limits. */ + readonly organizations?: OrganizationSettingsStore; +} + +/** Settings read from the trusted deployment file. Nothing in the control plane can write them. */ +export interface FileSettingsAuthority extends SettingsAuthorityBase { + readonly source: 'file'; + /** The file an administrator edits, shown so they know where to look. */ + readonly filePath: string; +} + +/** Settings kept in the store and edited from the dashboard. */ +export interface DashboardSettingsAuthority extends SettingsAuthorityBase { + readonly source: 'dashboard'; + save(settings: DeploymentSettings, actor: string): Promise; +} + +/** + * The deployment's settings, as the composition root backs them. + * + * Declared structurally so this package stays free of the store and the file system: `apps/web` + * builds one from `@code-zero/database` and the deployment file, and a test builds one from memory. + * Which of the two sources it is was decided in the deployment's environment before the process + * started; nothing a request carries can change it. + */ +export type DeploymentSettingsAuthority = FileSettingsAuthority | DashboardSettingsAuthority; + +/** + * What one run is held to: the deployment's settings and, on the managed cloud, the stricter limits + * of the organization it was started for. + */ +export interface RunPolicy { + readonly settings: DeploymentSettings; + readonly organization?: { readonly id: string; readonly limits: OrganizationLimits }; + /** + * Whether the run may only execute inside an isolated sandbox: the deployment's container + * sandbox, or a hosted sandbox provider's lease. True on the managed cloud, where a customer's + * repository must never run on the service's own host; a run that would reach neither is refused. + */ + readonly isolationRequired?: boolean; +} + +/** The limits a run is held to once its organization's are applied. */ +export function runExecution(policy: RunPolicy): ExecutionSettings { + return effectiveExecution(policy.settings.execution, policy.organization?.limits ?? null); +} + +/** + * The scheduler limits for a run under this policy. + * + * The deployment's limit bounds every run together; an organization's lower limit bounds only its + * own runs. Feeding the organization's value in as the deployment-wide one would let one cautious + * organization throttle every other tenant. + */ +export function scheduleLimits(policy: RunPolicy): ScheduleLimits { + const { organization } = policy; + return { + maxConcurrent: policy.settings.execution.maxConcurrentTasks, + ...(organization + ? { group: { key: organization.id, maxConcurrent: runExecution(policy).maxConcurrentTasks } } + : {}), + }; +} + +/** What the composition root knows about the connections it opened. */ +export interface DeploymentConnections { + database: boolean; + mail: boolean; + /** + * Whether a hosted sandbox provider (Cloudflare, Vercel, ViteHub) is wired into task execution. + * It brings its own image, so it counts as the sandbox whether or not a container is configured. + */ + hostedSandbox: boolean; +} + +/** + * Whether each service the deployment depends on is set up, from the settings and the connections. + * + * The model is reported only when the deployment names one: otherwise each repository chooses its + * own, and there is no single answer to give. Only the credential's presence is checked — never its + * value, and never whether the provider accepts it, which only a run can find out. + */ +export function deploymentServices( + settings: DeploymentSettings | null, + connections: DeploymentConnections, + environment: NodeJS.ProcessEnv, +): ServiceStatus[] { + return [ + ...(settings?.model + ? [ + { + service: 'model' as const, + configured: isModelConfigured(settings.model.provider, environment), + }, + ] + : []), + { + service: 'sandbox', + configured: connections.hostedSandbox || (settings?.runner ?? null) !== null, + }, + { service: 'database', configured: connections.database }, + { service: 'mail', configured: connections.mail }, + ]; +} From eedd69f1d13cbdeffb738bc31604da607c34e911 Mon Sep 17 00:00:00 2001 From: RedStar Date: Mon, 19 Oct 2026 12:04:37 +0200 Subject: [PATCH 4/8] feat(web): compose the settings authority and enforce it on every entry Build the settings authority in server/utils/settings.ts from the environment, the instance file and the store: exactly one source, the file's settings sections reported as ignored rather than merged when the dashboard is the source, and service status reported as configured or not, never how. Pass it into the RPC and OpenAPI contexts, and hold webhook deliveries and polling passes to the same policy: invalid settings start nothing, and a repository whose mode the settings disallow is skipped. --- apps/web/server/api/v1/[...].ts | 1 + apps/web/server/plugins/poller.ts | 16 +- apps/web/server/routes/rpc/[...].ts | 1 + .../web/server/routes/webhooks/github.post.ts | 6 + apps/web/server/utils/access.ts | 2 +- apps/web/server/utils/deployment.ts | 130 ++++++++--- apps/web/server/utils/errors.ts | 6 + apps/web/server/utils/settings.ts | 206 ++++++++++++++++++ apps/web/test/unit/settings.test.ts | 193 ++++++++++++++++ 9 files changed, 526 insertions(+), 35 deletions(-) create mode 100644 apps/web/server/utils/settings.ts create mode 100644 apps/web/test/unit/settings.test.ts diff --git a/apps/web/server/api/v1/[...].ts b/apps/web/server/api/v1/[...].ts index 9b0ebd5..6eac55b 100644 --- a/apps/web/server/api/v1/[...].ts +++ b/apps/web/server/api/v1/[...].ts @@ -51,6 +51,7 @@ export default defineEventHandler(async (event) => { apiKeys: apiKeyVerifier(serverAuth(event)), }), auditLog: auditLogStore, + settings: await settingsAuthority(), }, }); if (matched) return response; diff --git a/apps/web/server/plugins/poller.ts b/apps/web/server/plugins/poller.ts index f32eb9b..63d5552 100644 --- a/apps/web/server/plugins/poller.ts +++ b/apps/web/server/plugins/poller.ts @@ -1,4 +1,5 @@ import { githubTokenFromEnvironment, runTask } from '@code-zero/api'; +import { isModeAllowed } from '@code-zero/config'; import { GitHubPullRequests } from '@code-zero/source-control'; /** @@ -39,6 +40,13 @@ export default defineNitroPlugin((nitroApp) => { let stopped = false; async function pass(): Promise { + // Read on every pass, like the watch list: a settings change applies to the next pass, and a + // pass under invalid settings starts nothing rather than running on defaults nobody chose. + const policy = await deploymentRunPolicy(); + if (!policy.ok) { + console.error('[poll] the deployment settings are invalid; not starting reviews'); + return; + } const watched = await repositoryStore.watched(); // Only a GitHub source exists in this process; a repository configured for another provider // would otherwise be queried through it under owner/repo coordinates that provider never @@ -52,6 +60,12 @@ export default defineNitroPlugin((nitroApp) => { ); return []; } + if (!isModeAllowed(policy.value.settings.execution, repository.mode)) { + console.warn( + `[poll] ${repository.owner}/${repository.name} runs in ${repository.mode}, which the deployment settings do not allow; skipping`, + ); + return []; + } return [{ ...repository, provider: 'github' as const }]; }); if (repositories.length === 0) return; @@ -68,7 +82,7 @@ export default defineNitroPlugin((nitroApp) => { source: request.source, pullRequest: request.pullRequest, }, - { store: taskStore }, + { store: taskStore, policy: policy.value }, ), onError: (repository, error) => { console.error(`[poll] ${repository} failed`, error); diff --git a/apps/web/server/routes/rpc/[...].ts b/apps/web/server/routes/rpc/[...].ts index 21656be..0b7fcb2 100644 --- a/apps/web/server/routes/rpc/[...].ts +++ b/apps/web/server/routes/rpc/[...].ts @@ -47,6 +47,7 @@ export default defineEventHandler(async (event) => { apiKeys: apiKeyVerifier(auth), }), auditLog: auditLogStore, + settings: await settingsAuthority(), }, }); if (matched) return response; diff --git a/apps/web/server/routes/webhooks/github.post.ts b/apps/web/server/routes/webhooks/github.post.ts index 03a899f..8db7d24 100644 --- a/apps/web/server/routes/webhooks/github.post.ts +++ b/apps/web/server/routes/webhooks/github.post.ts @@ -17,6 +17,11 @@ export default defineEventHandler(async (event) => { const checkoutPath = checkoutPathFromEnvironment(process.env); if (!checkoutPath) throw errors.misconfigured('CODE_ZERO_CHECKOUT_PATH'); + // Refused while the settings are invalid, like every other run: a delivery must not start work on + // defaults nobody chose. + const policy = await deploymentRunPolicy(); + if (!policy.ok) throw errors.unavailable('The deployment settings are invalid'); + const request = toWebRequest(event); let outcome; try { @@ -31,6 +36,7 @@ export default defineEventHandler(async (event) => { store: taskStore, deliveryClaims: deliveryClaimStore, github: { token: githubTokenFromEnvironment() }, + policy: policy.value, }, ); } catch (error) { diff --git a/apps/web/server/utils/access.ts b/apps/web/server/utils/access.ts index 95041da..269b4f8 100644 --- a/apps/web/server/utils/access.ts +++ b/apps/web/server/utils/access.ts @@ -28,7 +28,7 @@ export function createControlPlaneAccessCache( * Who may call the control plane as a machine, composed from the two places its halves belong. * * The tokens are secrets and stay in `CODE_ZERO_CONTROL_PLANE_TOKENS`, beside the database - * password. What each of them may run is policy and comes from `code-zero.deployment.yml`, where + * password. What each of them may run is policy and comes from the instance file, `config.yml`, where * it is a readable map instead of the `name:mode|mode` string it used to be squeezed into. * * Resolved once: both halves are fixed for the life of the process, and re-reading them per diff --git a/apps/web/server/utils/deployment.ts b/apps/web/server/utils/deployment.ts index c828d11..3d69df2 100644 --- a/apps/web/server/utils/deployment.ts +++ b/apps/web/server/utils/deployment.ts @@ -1,50 +1,114 @@ -import { resolve } from 'node:path'; - import { defaultDeploymentConfig, DEPLOYMENT_CONFIG_FILE, describeDeploymentConfigIssues, - loadDeploymentConfig, + editionFromEnvironment, + LEGACY_DEPLOYMENT_CONFIG_FILE, + loadDeploymentFile, + locateDeploymentConfig, + settingsSourceFromEnvironment, type DeploymentConfig, + type DeploymentConfigIssue, + type DeploymentFile, + type Edition, + type SettingsSource, } from '@code-zero/config'; /** - * How this deployment behaves, read once from `code-zero.deployment.yml`. + * Which product this deployment is, and where its settings come from, as its environment says. * - * One file replaces the handful of comma-separated environment variables this policy used to be - * spelled in — a list of origins, a `name:mode|mode` grant string, a poll interval — each of which - * had its own ad-hoc format to get wrong. What stays in the environment is what a deployment - * already keeps there: secrets, and the two bootstrap values needed to find everything else - * (`DATABASE_URL`, and the path to this file). + * Both are bootstrap values the process reads before anything else, so they live in the + * environment beside `DATABASE_URL`, where only whoever runs the deployment can change them. No + * request reaches them: the settings page can show them, never move them. + */ +export interface DeploymentIdentity { + edition: Edition; + source: SettingsSource; + /** Why the environment could not be read. While any is present, every run is refused. */ + issues: readonly DeploymentConfigIssue[]; +} + +/** + * Read the edition and the settings source from the environment. * - * `CODE_ZERO_CONFIG` names the file; without it the process reads `code-zero.deployment.yml` from - * its working directory, and a deployment that has never needed to change anything ships no file - * at all and gets the defaults. + * An edition this process does not recognise is treated as the managed cloud, the locked one: + * nothing becomes editable because a variable was mistyped, and the issue it carries keeps every run + * refused until it is corrected. + */ +export function deploymentIdentity( + environment: Readonly> = process.env, +): DeploymentIdentity { + const edition = editionFromEnvironment(environment); + const resolved: Edition = edition.ok ? edition.value : 'cloud'; + const source = settingsSourceFromEnvironment(environment, resolved); + return { + edition: resolved, + source: source.ok ? source.value : 'file', + issues: [...(edition.ok ? [] : edition.issues), ...(source.ok ? [] : source.issues)], + }; +} + +/** The instance configuration file as this process read it, and where it was found. */ +export interface LoadedDeploymentFile extends DeploymentFile { + path: string; + /** True when it was found under its old name, `code-zero.deployment.yml`. */ + legacy: boolean; +} + +/** + * Read the instance configuration file — `config.yml`, or the path `CODE_ZERO_CONFIG` names. + * + * One file, decided once. When both `config.yml` and the old `code-zero.deployment.yml` exist and + * neither is named, nothing is read: the control-plane policy keeps its closed defaults and the + * settings are refused, rather than one file silently winning over the other. + */ +export async function readDeploymentFile( + environment: Readonly>, + directory: string, + edition: Edition, +): Promise { + const located = await locateDeploymentConfig(environment, directory); + if (!located.ok) + return { + config: structuredClone(defaultDeploymentConfig), + issues: located.issues, + settings: { ok: false, issues: located.issues }, + declaresSettings: false, + path: `${directory}/${DEPLOYMENT_CONFIG_FILE}`, + legacy: false, + }; + return { ...(await loadDeploymentFile(located.value.path, edition)), ...located.value }; +} + +/** + * The instance configuration, read once per process and awaited by every reader. * - * Resolved once and awaited by every reader, rather than re-read per request: this is deployment - * policy, which changes when the process restarts. Repository configuration is the opposite — it - * changes while the process runs — which is exactly why it lives in the store instead. + * Deployment policy changes when the process restarts. What changes while it runs — repositories, + * and settings kept in the dashboard — lives in the store instead. */ -let pending: Promise | undefined; +let pending: Promise | undefined; -export function deploymentConfig(): Promise { - pending ??= read(); +export function deploymentFile(): Promise { + pending ??= readDeploymentFile(process.env, process.cwd(), deploymentIdentity().edition).then( + (file) => { + if (file.issues.length > 0) + // Reported rather than thrown: a refused control-plane field falls back to its own closed + // default, and the process starting with a loud complaint beats a dashboard that will not + // load at all. Every issue names its path, so the fix is one edit. + console.error( + `[config] ${file.path} has problems; the defaults stand where a value was refused:\n${describeDeploymentConfigIssues(file.issues)}`, + ); + if (file.legacy) + console.warn( + `[config] ${file.path} uses the old name ${LEGACY_DEPLOYMENT_CONFIG_FILE}; rename it to ${DEPLOYMENT_CONFIG_FILE}.`, + ); + return file; + }, + ); return pending; } -async function read(): Promise { - const path = resolve(process.env.CODE_ZERO_CONFIG?.trim() || DEPLOYMENT_CONFIG_FILE); - const { config, issues } = await loadDeploymentConfig(path); - if (issues.length > 0) { - // Reported rather than thrown: a malformed field falls back to its own default, and the - // process starting with a loud complaint beats a dashboard that will not load at all because - // one line of YAML is wrong. Every issue names its path, so the fix is one edit. - console.error( - `[config] ${path} has problems; the defaults stand where a value was refused:\n${describeDeploymentConfigIssues(issues)}`, - ); - } - return config; +/** The control-plane and polling half of the instance configuration. */ +export async function deploymentConfig(): Promise { + return (await deploymentFile()).config; } - -/** The defaults, for a caller that must answer before the file has been read. */ -export const deploymentDefaults: DeploymentConfig = defaultDeploymentConfig; diff --git a/apps/web/server/utils/errors.ts b/apps/web/server/utils/errors.ts index 50dd271..ef29d8e 100644 --- a/apps/web/server/utils/errors.ts +++ b/apps/web/server/utils/errors.ts @@ -40,6 +40,12 @@ export const errors = { misconfigured: (variable: string) => fail({ status: 503, message: `${variable} is not configured` }), + /** + * The deployment cannot take work until its operator fixes something, such as invalid settings. + * 503 rather than 500: nothing failed unexpectedly, and the same request succeeds once it is fixed. + */ + unavailable: (reason: string) => fail({ status: 503, message: reason }), + /** * The caller is signed in, but the session does not carry the role the route requires. Distinct * from the 401 `requireUserSession` raises for an absent session: signing in again would not diff --git a/apps/web/server/utils/settings.ts b/apps/web/server/utils/settings.ts new file mode 100644 index 0000000..f15a403 --- /dev/null +++ b/apps/web/server/utils/settings.ts @@ -0,0 +1,206 @@ +import { + deploymentServices, + type DeploymentConnections, + type DeploymentSettingsAuthority, + type OrganizationSettingsStore, + type RunPolicy, + type SettingsState, +} from '@code-zero/api'; +import { organizationRoles } from '@code-zero/auth/access'; +import { + defaultDeploymentSettings, + deploymentSettingsDocument, + LEGACY_DEPLOYMENT_CONFIG_FILE, + parseDeploymentSettings, + type DeploymentConfigIssue, + type Parsed, +} from '@code-zero/config'; +import { + optionalDatabaseUrlFromEnvironment, + organizationMemberRole, + readDeploymentSettings, + readOrganizationSettings, + saveDeploymentSettings, + saveOrganizationSettings, + type StoredSettings, +} from '@code-zero/database'; +import { mailProviderNameFromEnvironment } from '@code-zero/mail'; + +import { database } from './database.js'; +import { + deploymentFile, + deploymentIdentity, + type DeploymentIdentity, + type LoadedDeploymentFile, +} from './deployment.js'; + +/** The stored deployment settings, behind the two calls the dashboard source needs. */ +export interface StoredSettingsSource { + read(): Promise; + save(document: Record, actor: string): Promise; +} + +/** Everything the settings authority reads from, injected so a test can hold it all in memory. */ +export interface SettingsAuthorityDependencies { + identity: DeploymentIdentity; + file: LoadedDeploymentFile; + stored: StoredSettingsSource; + organizations: OrganizationSettingsStore; + connections: DeploymentConnections; + environment: Readonly>; +} + +/** + * The deployment's settings, from exactly one source. + * + * With the file as the source, the file's settings sections are the settings and nothing writes + * them. With the dashboard as the source, the store is, and the file's settings sections — if it + * still has any — are reported as ignored rather than merged: a value has one home, and the + * operator is told which one it is not. The file's control-plane and polling sections are read + * either way; they are bootstrap policy, not settings. + * + * Every read goes back to the source, so a saved change is what the next run is held to. + */ +export function createSettingsAuthority( + dependencies: SettingsAuthorityDependencies, +): DeploymentSettingsAuthority { + const { identity, file, stored, organizations, connections, environment } = dependencies; + + async function read(): Promise { + if (identity.issues.length > 0) return { ok: false, issues: identity.issues }; + const notes = file.legacy ? [renameNote(file)] : []; + if (identity.source === 'file') { + if (!file.settings.ok) return { ok: false, issues: file.settings.issues }; + return { ok: true, settings: file.settings.value, saved: null, warnings: notes }; + } + const record = await stored.read(); + const parsed: Parsed = record + ? parseDeploymentSettings(record.document) + : { ok: true, value: defaultDeploymentSettings }; + if (!parsed.ok) return { ok: false, issues: parsed.issues }; + return { + ok: true, + settings: parsed.value, + saved: record ? { by: record.updatedBy, at: record.updatedAt.toISOString() } : null, + warnings: file.declaresSettings ? [...notes, ignoredNote(file)] : notes, + }; + } + + async function services() { + const state = await read(); + return deploymentServices(state.ok ? state.settings : null, connections, environment); + } + + const shared = { + edition: identity.edition, + read, + services, + ...(identity.edition === 'cloud' ? { organizations } : {}), + }; + if (identity.source === 'dashboard') + return { + ...shared, + source: 'dashboard', + save: (settings, actor) => stored.save(deploymentSettingsDocument(settings), actor), + }; + return { ...shared, source: 'file', filePath: file.path }; +} + +function renameNote(file: LoadedDeploymentFile): DeploymentConfigIssue { + return { + path: '$', + message: `${file.path} uses the old name ${LEGACY_DEPLOYMENT_CONFIG_FILE}; rename it to config.yml.`, + }; +} + +function ignoredNote(file: LoadedDeploymentFile): DeploymentConfigIssue { + return { + path: '$', + message: `${file.path} states settings, which are ignored because the dashboard is their source. Remove them from the file.`, + }; +} + +/** + * Whether an organization role may change the organization's policy. + * + * Asked of `@code-zero/auth`'s own role definitions rather than a list of role names, so the + * organization plugin and this check cannot disagree about who manages policy. A membership can + * hold several comma-separated roles, the way Better Auth stores them; any one that grants + * `policy: write` is enough. + */ +export function managesOrganizationPolicy(role: string): boolean { + return role + .split(',') + .map((name) => name.trim()) + .some((name) => { + const definition = Object.entries(organizationRoles).find(([key]) => key === name)?.[1]; + const statements: Readonly> = + definition?.statements ?? {}; + return statements.policy?.includes('write') ?? false; + }); +} + +/** Organization limits and memberships, read from Postgres on every call. */ +function postgresOrganizationSettings(): OrganizationSettingsStore { + return { + async membership(organizationId, userId) { + const role = await organizationMemberRole(database(), organizationId, userId); + return role === null ? null : { role, managesPolicy: managesOrganizationPolicy(role) }; + }, + read: async (organizationId) => + (await readOrganizationSettings(database(), organizationId))?.document ?? null, + save: async (organizationId, document, actor) => { + await saveOrganizationSettings(database(), organizationId, document, actor); + }, + }; +} + +function postgresStoredSettings(): StoredSettingsSource { + return { + read: () => readDeploymentSettings(database()), + save: async (document, actor) => { + await saveDeploymentSettings(database(), document, actor); + }, + }; +} + +/** + * The authority this process uses, built once the instance file has been read. + * + * No hosted sandbox provider is wired into this app's task execution yet, so the sandbox counts as + * set up only when the settings name a container sandbox. + */ +let pending: Promise | undefined; + +export function settingsAuthority(): Promise { + pending ??= deploymentFile().then((file) => + createSettingsAuthority({ + identity: deploymentIdentity(), + file, + stored: postgresStoredSettings(), + organizations: postgresOrganizationSettings(), + connections: { + database: optionalDatabaseUrlFromEnvironment(process.env) !== undefined, + mail: mailProviderNameFromEnvironment(process.env) !== 'console', + hostedSandbox: false, + }, + environment: process.env, + }), + ); + return pending; +} + +/** + * The policy work this deployment starts on its own — a webhook delivery, a polling pass — runs + * under: the deployment's settings, with no organization, since that work belongs to no customer. + * Refused while the settings are invalid, like every other run. + */ +export async function deploymentRunPolicy(): Promise> { + const authority = await settingsAuthority(); + const state = await authority.read(); + if (!state.ok) return { ok: false, issues: state.issues }; + return { + ok: true, + value: { settings: state.settings, isolationRequired: authority.edition === 'cloud' }, + }; +} diff --git a/apps/web/test/unit/settings.test.ts b/apps/web/test/unit/settings.test.ts new file mode 100644 index 0000000..0dbe271 --- /dev/null +++ b/apps/web/test/unit/settings.test.ts @@ -0,0 +1,193 @@ +import { mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { OrganizationSettingsStore } from '@code-zero/api'; +import { defaultDeploymentSettings, parseDeploymentFile } from '@code-zero/config'; +import type { StoredSettings } from '@code-zero/database'; +import { describe, expect, it } from 'vitest'; + +import { + deploymentIdentity, + readDeploymentFile, + type LoadedDeploymentFile, +} from '../../server/utils/deployment.js'; +import { + createSettingsAuthority, + managesOrganizationPolicy, + type StoredSettingsSource, +} from '../../server/utils/settings.js'; + +const FILE_WITH_SETTINGS = 'execution:\n max_concurrent_tasks: 2\n'; + +function file(text: string, overrides: Partial = {}): LoadedDeploymentFile { + return { + ...parseDeploymentFile(text), + path: '/srv/code-zero/config.yml', + legacy: false, + ...overrides, + }; +} + +function memoryStore(initial: StoredSettings | null = null) { + let current = initial; + const writes: Record[] = []; + const store: StoredSettingsSource = { + read: () => Promise.resolve(current), + save: (document, actor) => { + writes.push(document); + current = { document, updatedBy: actor, updatedAt: new Date('2026-10-18T10:00:00Z') }; + return Promise.resolve(); + }, + }; + return { store, writes }; +} + +const noOrganizations: OrganizationSettingsStore = { + membership: () => Promise.resolve(null), + read: () => Promise.resolve(null), + save: () => Promise.resolve(), +}; + +function authority( + environment: Record, + loaded: LoadedDeploymentFile, + stored = memoryStore().store, +) { + return createSettingsAuthority({ + identity: deploymentIdentity(environment), + file: loaded, + stored, + organizations: noOrganizations, + connections: { database: true, mail: false, hostedSandbox: false }, + environment, + }); +} + +describe('createSettingsAuthority', () => { + it('reads the settings from the file while the file is the source', async () => { + const settings = authority({}, file(FILE_WITH_SETTINGS)); + + expect(settings.source).toBe('file'); + await expect(settings.read()).resolves.toMatchObject({ + ok: true, + settings: { execution: { maxConcurrentTasks: 2 } }, + }); + }); + + it('reads only the store while the dashboard is the source, and says the file is ignored', async () => { + const settings = authority( + { CODE_ZERO_SETTINGS_SOURCE: 'dashboard' }, + file(FILE_WITH_SETTINGS), + ); + + const state = await settings.read(); + + expect(state).toMatchObject({ ok: true, settings: defaultDeploymentSettings }); + expect(state.ok && state.warnings).toHaveLength(1); + }); + + it('saves the document the file source would read back', async () => { + const { store, writes } = memoryStore(); + const settings = authority({ CODE_ZERO_SETTINGS_SOURCE: 'dashboard' }, file(''), store); + if (settings.source !== 'dashboard') throw new Error('expected the dashboard source'); + + await settings.save( + { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, maxConcurrentTasks: 3 }, + }, + 'admin@example.test', + ); + + expect(writes).toEqual([ + { + execution: { + max_concurrent_tasks: 3, + command_timeout_minutes: 30, + allowed_modes: ['observe', 'suggest', 'fix', 'autonomous'], + max_network: 'full', + }, + }, + ]); + await expect(settings.read()).resolves.toMatchObject({ + ok: true, + settings: { execution: { maxConcurrentTasks: 3 } }, + saved: { by: 'admin@example.test' }, + }); + }); + + it('refuses a stored document that no longer parses instead of running on the defaults', async () => { + const settings = authority( + { CODE_ZERO_SETTINGS_SOURCE: 'dashboard' }, + file(''), + memoryStore({ + document: { execution: { max_concurrent_tasks: 'all' } }, + updatedBy: 'someone', + updatedAt: new Date(), + }).store, + ); + + await expect(settings.read()).resolves.toMatchObject({ ok: false }); + }); + + it('locks a deployment whose edition variable it cannot read, and refuses its settings', async () => { + const settings = authority({ CODE_ZERO_EDITION: 'clod' }, file('')); + + expect(settings.edition).toBe('cloud'); + await expect(settings.read()).resolves.toMatchObject({ ok: false }); + }); + + it('asks the operator to rename a file still using the old name', async () => { + const settings = authority({}, file('', { legacy: true })); + + const state = await settings.read(); + + expect(state.ok && state.warnings).toHaveLength(1); + }); +}); + +describe('readDeploymentFile', () => { + it('reads neither file when config.yml and the old name both exist', async () => { + const directory = await mkdtemp(join(tmpdir(), 'code-zero-instance-')); + await writeFile(join(directory, 'config.yml'), FILE_WITH_SETTINGS, 'utf8'); + await writeFile( + join(directory, 'code-zero.deployment.yml'), + 'control_plane:\n origins: [https://ops.example.com]\n', + 'utf8', + ); + + const loaded = await readDeploymentFile({}, directory, 'self-hosted'); + + expect(loaded.settings.ok).toBe(false); + expect(loaded.config.controlPlane.origins).toEqual([]); + }); + + it('reads the file CODE_ZERO_CONFIG names', async () => { + const directory = await mkdtemp(join(tmpdir(), 'code-zero-instance-')); + await writeFile(join(directory, 'instance.yml'), FILE_WITH_SETTINGS, 'utf8'); + + const loaded = await readDeploymentFile( + { CODE_ZERO_CONFIG: 'instance.yml' }, + directory, + 'self-hosted', + ); + + expect(loaded.settings).toMatchObject({ + ok: true, + value: { execution: { maxConcurrentTasks: 2 } }, + }); + }); +}); + +describe('managesOrganizationPolicy', () => { + it.each([ + ['owner', true], + ['admin', true], + ['member', false], + ['member, admin', true], + ['billing', false], + ])('answers %j with %j from the organization role definitions', (role, expected) => { + expect(managesOrganizationPolicy(role)).toBe(expected); + }); +}); From 78c82d09def568815b3c051e68908be22415c85b Mon Sep 17 00:00:00 2001 From: RedStar Date: Mon, 19 Oct 2026 12:04:48 +0200 Subject: [PATCH 5/8] feat(web): add the deployment settings page Add /settings and a settings module on the typed oRPC client. - Self-hosted, file source: the settings read-only, the file to edit, every issue by path when they are invalid, service status, and YAML export. - Self-hosted, dashboard source: an editable form for execution limits, model and container runner, with bounds as hints only; the server's per-field issues are shown beside each field, plus pending, saved and error states, who saved last and when, and download or copy as config.yml. - Managed cloud: service status, plan limits read-only, and the active organization's limits, editable for owners and admins and read-only for members, with the effective limits. The page re-reads when the sidebar switches organization. - A refusal (for example a non-admin on self-hosted) renders as a message. The timeout is labelled per command. Controls the server does not enforce are not shown. Adds the Settings nav entry and the g s shortcut, EN and IT copy, and unit and component tests. --- apps/web/app/pages/(dashboard)/settings.vue | 182 ++++++++ .../modules/settings/components/Execution.vue | 34 ++ .../modules/settings/components/Export.vue | 82 ++++ .../web/modules/settings/components/Field.vue | 41 ++ apps/web/modules/settings/components/Form.vue | 305 ++++++++++++ .../modules/settings/components/IssueList.vue | 18 + .../components/OrganizationLimits.vue | 216 +++++++++ .../modules/settings/components/Problems.vue | 47 ++ .../modules/settings/components/SaveBar.vue | 54 +++ .../modules/settings/components/Services.vue | 44 ++ .../modules/settings/components/Source.vue | 91 ++++ .../web/modules/settings/components/State.vue | 42 ++ .../modules/settings/components/Summary.vue | 62 +++ .../composables/useDeploymentSettingsForm.ts | 35 ++ .../composables/useOrganizationLimitsForm.ts | 39 ++ .../settings/composables/useSettings.ts | 43 ++ .../settings/composables/useSettingsExport.ts | 36 ++ .../composables/useSettingsForm.test.ts | 219 +++++++++ .../settings/composables/useSettingsForm.ts | 97 ++++ apps/web/modules/settings/index.ts | 20 + apps/web/modules/settings/utils/form.test.ts | 316 +++++++++++++ apps/web/modules/settings/utils/form.ts | 378 +++++++++++++++ apps/web/modules/settings/utils/labels.ts | 44 ++ .../modules/shared/components/app/Sidebar.vue | 3 + .../shared/composables/useAppShortcuts.ts | 2 + apps/web/nuxt.config.ts | 3 + .../test/nuxt/components/SettingsForm.spec.ts | 100 ++++ apps/web/test/nuxt/pages/settings.spec.ts | 192 ++++++++ apps/web/uno.config.ts | 5 + packages/i18n/locales/en/dashboard.json | 150 +++++- packages/i18n/locales/it/dashboard.json | 150 +++++- packages/i18n/schemas/dashboard.schema.json | 438 ++++++++++++++++++ 32 files changed, 3484 insertions(+), 4 deletions(-) create mode 100644 apps/web/app/pages/(dashboard)/settings.vue create mode 100644 apps/web/modules/settings/components/Execution.vue create mode 100644 apps/web/modules/settings/components/Export.vue create mode 100644 apps/web/modules/settings/components/Field.vue create mode 100644 apps/web/modules/settings/components/Form.vue create mode 100644 apps/web/modules/settings/components/IssueList.vue create mode 100644 apps/web/modules/settings/components/OrganizationLimits.vue create mode 100644 apps/web/modules/settings/components/Problems.vue create mode 100644 apps/web/modules/settings/components/SaveBar.vue create mode 100644 apps/web/modules/settings/components/Services.vue create mode 100644 apps/web/modules/settings/components/Source.vue create mode 100644 apps/web/modules/settings/components/State.vue create mode 100644 apps/web/modules/settings/components/Summary.vue create mode 100644 apps/web/modules/settings/composables/useDeploymentSettingsForm.ts create mode 100644 apps/web/modules/settings/composables/useOrganizationLimitsForm.ts create mode 100644 apps/web/modules/settings/composables/useSettings.ts create mode 100644 apps/web/modules/settings/composables/useSettingsExport.ts create mode 100644 apps/web/modules/settings/composables/useSettingsForm.test.ts create mode 100644 apps/web/modules/settings/composables/useSettingsForm.ts create mode 100644 apps/web/modules/settings/index.ts create mode 100644 apps/web/modules/settings/utils/form.test.ts create mode 100644 apps/web/modules/settings/utils/form.ts create mode 100644 apps/web/modules/settings/utils/labels.ts create mode 100644 apps/web/test/nuxt/components/SettingsForm.spec.ts create mode 100644 apps/web/test/nuxt/pages/settings.spec.ts diff --git a/apps/web/app/pages/(dashboard)/settings.vue b/apps/web/app/pages/(dashboard)/settings.vue new file mode 100644 index 0000000..d3cc490 --- /dev/null +++ b/apps/web/app/pages/(dashboard)/settings.vue @@ -0,0 +1,182 @@ + + + diff --git a/apps/web/modules/settings/components/Execution.vue b/apps/web/modules/settings/components/Execution.vue new file mode 100644 index 0000000..e3cdbf2 --- /dev/null +++ b/apps/web/modules/settings/components/Execution.vue @@ -0,0 +1,34 @@ + + + diff --git a/apps/web/modules/settings/components/Export.vue b/apps/web/modules/settings/components/Export.vue new file mode 100644 index 0000000..5cf9053 --- /dev/null +++ b/apps/web/modules/settings/components/Export.vue @@ -0,0 +1,82 @@ + + + diff --git a/apps/web/modules/settings/components/Field.vue b/apps/web/modules/settings/components/Field.vue new file mode 100644 index 0000000..5243c73 --- /dev/null +++ b/apps/web/modules/settings/components/Field.vue @@ -0,0 +1,41 @@ + + + diff --git a/apps/web/modules/settings/components/Form.vue b/apps/web/modules/settings/components/Form.vue new file mode 100644 index 0000000..edb59a5 --- /dev/null +++ b/apps/web/modules/settings/components/Form.vue @@ -0,0 +1,305 @@ + + + diff --git a/apps/web/modules/settings/components/IssueList.vue b/apps/web/modules/settings/components/IssueList.vue new file mode 100644 index 0000000..ae0444a --- /dev/null +++ b/apps/web/modules/settings/components/IssueList.vue @@ -0,0 +1,18 @@ + + + diff --git a/apps/web/modules/settings/components/OrganizationLimits.vue b/apps/web/modules/settings/components/OrganizationLimits.vue new file mode 100644 index 0000000..40a810b --- /dev/null +++ b/apps/web/modules/settings/components/OrganizationLimits.vue @@ -0,0 +1,216 @@ + + + diff --git a/apps/web/modules/settings/components/Problems.vue b/apps/web/modules/settings/components/Problems.vue new file mode 100644 index 0000000..bc5f165 --- /dev/null +++ b/apps/web/modules/settings/components/Problems.vue @@ -0,0 +1,47 @@ + + + diff --git a/apps/web/modules/settings/components/SaveBar.vue b/apps/web/modules/settings/components/SaveBar.vue new file mode 100644 index 0000000..c7de2ae --- /dev/null +++ b/apps/web/modules/settings/components/SaveBar.vue @@ -0,0 +1,54 @@ + + + diff --git a/apps/web/modules/settings/components/Services.vue b/apps/web/modules/settings/components/Services.vue new file mode 100644 index 0000000..53dde91 --- /dev/null +++ b/apps/web/modules/settings/components/Services.vue @@ -0,0 +1,44 @@ + + + diff --git a/apps/web/modules/settings/components/Source.vue b/apps/web/modules/settings/components/Source.vue new file mode 100644 index 0000000..ee45cdf --- /dev/null +++ b/apps/web/modules/settings/components/Source.vue @@ -0,0 +1,91 @@ + + + diff --git a/apps/web/modules/settings/components/State.vue b/apps/web/modules/settings/components/State.vue new file mode 100644 index 0000000..11225cf --- /dev/null +++ b/apps/web/modules/settings/components/State.vue @@ -0,0 +1,42 @@ + + + diff --git a/apps/web/modules/settings/components/Summary.vue b/apps/web/modules/settings/components/Summary.vue new file mode 100644 index 0000000..9f95b49 --- /dev/null +++ b/apps/web/modules/settings/components/Summary.vue @@ -0,0 +1,62 @@ + + + diff --git a/apps/web/modules/settings/composables/useDeploymentSettingsForm.ts b/apps/web/modules/settings/composables/useDeploymentSettingsForm.ts new file mode 100644 index 0000000..f551072 --- /dev/null +++ b/apps/web/modules/settings/composables/useDeploymentSettingsForm.ts @@ -0,0 +1,35 @@ +import type { SettingsView } from '@code-zero/api'; +import { ref } from 'vue'; + +import { + formFromView, + settingsFieldIssues, + settingsInputFromForm, + type SelfHostedSettingsView, + type SettingsInput, +} from '../utils/form'; +import { useSettingsForm } from './useSettingsForm'; + +/** `$orpc.settings.update` from the page that owns the client. */ +export type SettingsSaver = (input: SettingsInput) => Promise; + +/** + * The deployment settings form, for a self-hosted deployment whose source is the dashboard. + * + * Offered only where the view says the settings are `editable`; the server refuses the save + * anywhere else regardless, so a page that rendered this by mistake could still change nothing. + */ +export function useDeploymentSettingsForm( + view: () => SelfHostedSettingsView, + save: SettingsSaver, + onSaved: (next: SettingsView) => void, +) { + return useSettingsForm({ + form: ref(formFromView(view())), + initial: () => formFromView(view()), + build: settingsInputFromForm, + fieldIssues: settingsFieldIssues, + save, + onSaved, + }); +} diff --git a/apps/web/modules/settings/composables/useOrganizationLimitsForm.ts b/apps/web/modules/settings/composables/useOrganizationLimitsForm.ts new file mode 100644 index 0000000..3baf2ce --- /dev/null +++ b/apps/web/modules/settings/composables/useOrganizationLimitsForm.ts @@ -0,0 +1,39 @@ +import type { OrganizationSettingsView } from '@code-zero/api'; +import type { ExecutionSettings } from '@code-zero/config'; +import { ref } from 'vue'; + +import { + organizationFieldIssues, + organizationFormFromView, + organizationInputFromForm, + type OrganizationLimitsInput, +} from '../utils/form'; +import { useSettingsForm } from './useSettingsForm'; + +/** `$orpc.settings.organization.update` from the page that owns the client. */ +export type OrganizationLimitsSaver = (request: { + organizationId: string; + limits: OrganizationLimitsInput; +}) => Promise; + +/** + * One organization's own limits on the managed cloud, for an owner or admin. + * + * The plan is the ceiling: the server refuses any value above it, and the issue it reports is + * shown beside the field. Emptying a field hands that limit back to the plan. + */ +export function useOrganizationLimitsForm( + view: () => OrganizationSettingsView, + plan: () => ExecutionSettings | null, + save: OrganizationLimitsSaver, + onSaved: (next: OrganizationSettingsView) => void, +) { + return useSettingsForm({ + form: ref(organizationFormFromView(view(), plan())), + initial: () => organizationFormFromView(view(), plan()), + build: organizationInputFromForm, + fieldIssues: organizationFieldIssues, + save: (limits: OrganizationLimitsInput) => save({ organizationId: view().id, limits }), + onSaved, + }); +} diff --git a/apps/web/modules/settings/composables/useSettings.ts b/apps/web/modules/settings/composables/useSettings.ts new file mode 100644 index 0000000..105dcf9 --- /dev/null +++ b/apps/web/modules/settings/composables/useSettings.ts @@ -0,0 +1,43 @@ +// Imported explicitly rather than relying on Nuxt auto-imports, so the dependency stays visible at +// the call site; `nuxt typecheck` resolves either form. +import type { SettingsView } from '@code-zero/api'; +import { shallowRef } from 'vue'; + +import { classifySettingsError, type SettingsError } from '../utils/form'; + +/** Reads the settings page's read model; `$orpc.settings.get` from the page that owns the client. */ +export type SettingsReader = () => Promise; + +/** + * The deployment's settings as `settings.get` describes them for the signed-in reader. + * + * Client-side only, like the audit log: the procedure authenticates the browser's session, and + * what it returns depends on who asks. A failed read clears the view rather than leaving a stale + * one on screen, because a page that still shows settings after a refusal reads as permission. + */ +export function useSettings(read: SettingsReader) { + const view = shallowRef(null); + const pending = shallowRef(false); + const error = shallowRef(null); + + async function refresh(): Promise { + pending.value = true; + error.value = null; + try { + view.value = await read(); + } catch (caught) { + view.value = null; + error.value = classifySettingsError(caught); + } finally { + pending.value = false; + } + } + + /** Takes the view a successful save answered with, so the page shows what the server stored. */ + function replace(next: SettingsView): void { + view.value = next; + error.value = null; + } + + return { view, pending, error, refresh, replace }; +} diff --git a/apps/web/modules/settings/composables/useSettingsExport.ts b/apps/web/modules/settings/composables/useSettingsExport.ts new file mode 100644 index 0000000..f344a93 --- /dev/null +++ b/apps/web/modules/settings/composables/useSettingsExport.ts @@ -0,0 +1,36 @@ +import { shallowRef } from 'vue'; + +import { classifySettingsError, type SettingsError } from '../utils/form'; + +/** `$orpc.settings.export` from the page that owns the client. */ +export type SettingsExporter = () => Promise<{ yaml: string }>; + +/** + * The settings as the YAML `config.yml` would hold, fetched only when asked for. + * + * The server renders it with every environment secret redacted, and the settings themselves refuse + * anything shaped like a credential, so what arrives here is safe to download or copy whole. + */ +export function useSettingsExport(read: SettingsExporter) { + const yaml = shallowRef(null); + const pending = shallowRef(false); + const error = shallowRef(null); + + async function load(): Promise { + pending.value = true; + error.value = null; + try { + const result = await read(); + yaml.value = result.yaml; + return result.yaml; + } catch (caught) { + yaml.value = null; + error.value = classifySettingsError(caught); + return null; + } finally { + pending.value = false; + } + } + + return { yaml, pending, error, load }; +} diff --git a/apps/web/modules/settings/composables/useSettingsForm.test.ts b/apps/web/modules/settings/composables/useSettingsForm.test.ts new file mode 100644 index 0000000..03b28aa --- /dev/null +++ b/apps/web/modules/settings/composables/useSettingsForm.test.ts @@ -0,0 +1,219 @@ +import type { OrganizationSettingsView, SettingsView } from '@code-zero/api'; +import type { DeploymentSettings } from '@code-zero/config'; +import { ORPCError } from '@orpc/client'; +import { describe, expect, it, vi } from 'vitest'; +import { nextTick, shallowRef } from 'vue'; + +import type { SelfHostedSettingsView, SettingsInput } from '../utils/form.js'; +import { useDeploymentSettingsForm } from './useDeploymentSettingsForm.js'; +import { + useOrganizationLimitsForm, + type OrganizationLimitsSaver, +} from './useOrganizationLimitsForm.js'; +import { useSettings } from './useSettings.js'; + +/** + * The composables take the procedures they call as arguments, so these specs hand them stubs and + * stay in the plain-Node `unit` project, with no Nuxt app to boot. + */ +const SETTINGS: DeploymentSettings = { + execution: { + maxConcurrentTasks: 4, + commandTimeoutMinutes: 30, + allowedModes: ['observe', 'suggest', 'fix', 'autonomous'], + maxNetwork: 'full', + }, + model: null, + runner: null, +}; + +function view(settings: DeploymentSettings = SETTINGS): SelfHostedSettingsView { + return { + edition: 'self-hosted', + source: 'dashboard', + editable: true, + filePath: null, + state: { ok: true, settings, saved: null, warnings: [] }, + services: [], + bounds: { + maxConcurrentTasks: { minimum: 1, maximum: 64 }, + commandTimeoutMinutes: { minimum: 1, maximum: 1440 }, + }, + }; +} + +describe('useDeploymentSettingsForm', () => { + it('saves the mapped input and reports the stored view as saved', async () => { + const stored = view({ + ...SETTINGS, + execution: { ...SETTINGS.execution, maxConcurrentTasks: 2 }, + }); + const save = vi.fn<(input: SettingsInput) => Promise>().mockResolvedValue(stored); + const onSaved = vi.fn<(next: SettingsView) => void>(); + const editor = useDeploymentSettingsForm(() => view(), save, onSaved); + + editor.form.value.maxConcurrentTasks = '2'; + await editor.submit(); + + expect(save).toHaveBeenCalledWith({ + execution: { + maxConcurrentTasks: 2, + commandTimeoutMinutes: 30, + allowedModes: ['observe', 'suggest', 'fix', 'autonomous'], + maxNetwork: 'full', + }, + model: null, + runner: null, + }); + expect(onSaved).toHaveBeenCalledWith(stored); + expect(editor.status.value).toBe('saved'); + }); + + it('stops saying saved once the form no longer matches what was saved', async () => { + const editor = useDeploymentSettingsForm( + () => view(), + () => Promise.resolve(view()), + () => {}, + ); + + await editor.submit(); + expect(editor.saved.value).toBe(true); + + editor.form.value.commandTimeoutMinutes = '10'; + expect(editor.saved.value).toBe(false); + expect(editor.dirty.value).toBe(true); + }); + + it("puts each of the server's issues beside its field", async () => { + const refusal = new ORPCError('BAD_REQUEST', { + data: { + issues: [ + { + path: '$.execution.command_timeout_minutes', + message: 'Expected an integer from 1 to 1440.', + }, + { path: '$.runner.image', message: 'Expected a container image reference.' }, + ], + }, + }); + const onSaved = vi.fn<(next: SettingsView) => void>(); + const editor = useDeploymentSettingsForm( + () => view(), + () => Promise.reject(refusal), + onSaved, + ); + + await editor.submit(); + + expect(editor.status.value).toBe('error'); + expect(editor.error.value).toBe('invalid'); + expect(editor.issues.value).toEqual({ + commandTimeoutMinutes: [{ text: 'Expected an integer from 1 to 1440.' }], + runnerImage: [{ text: 'Expected a container image reference.' }], + }); + expect(onSaved).not.toHaveBeenCalled(); + }); + + it('reports a refusal that is not about the values with no field issues', async () => { + const editor = useDeploymentSettingsForm( + () => view(), + () => Promise.reject(new ORPCError('FORBIDDEN', { message: 'requires the admin role' })), + () => {}, + ); + + await editor.submit(); + + expect(editor.error.value).toBe('forbidden'); + expect(editor.issues.value).toEqual({}); + }); + + it('never calls the server for a form it cannot express', async () => { + const save = vi.fn<(input: SettingsInput) => Promise>(); + const editor = useDeploymentSettingsForm( + () => view(), + save, + () => {}, + ); + + editor.form.value.maxConcurrentTasks = ''; + await editor.submit(); + + expect(save).not.toHaveBeenCalled(); + expect(editor.issues.value.maxConcurrentTasks).toEqual([ + { key: 'dashboard.settings.form.numberRequired' }, + ]); + }); + + it('starts the draft over when the settings it describes change', async () => { + const current = shallowRef(view()); + const editor = useDeploymentSettingsForm( + () => current.value, + vi.fn(), + () => {}, + ); + editor.form.value.maxConcurrentTasks = '9'; + + current.value = view({ + ...SETTINGS, + execution: { ...SETTINGS.execution, maxConcurrentTasks: 3 }, + }); + await nextTick(); + + expect(editor.form.value.maxConcurrentTasks).toBe('3'); + expect(editor.dirty.value).toBe(false); + }); +}); + +describe('useOrganizationLimitsForm', () => { + it('saves for the organization the view names, and reports a value above the plan', async () => { + const organization: OrganizationSettingsView = { + id: 'org_acme', + role: 'admin', + editable: true, + limits: { maxConcurrentTasks: null, commandTimeoutMinutes: null, allowedModes: null }, + invalid: false, + effective: null, + }; + const save = vi.fn().mockRejectedValue( + new ORPCError('BAD_REQUEST', { + data: { + issues: [{ path: '$.max_concurrent_tasks', message: 'The plan allows at most 4.' }], + }, + }), + ); + const editor = useOrganizationLimitsForm( + () => organization, + () => null, + save, + () => {}, + ); + + editor.form.value.maxConcurrentTasks = '8'; + await editor.submit(); + + expect(save).toHaveBeenCalledWith({ + organizationId: 'org_acme', + limits: { maxConcurrentTasks: 8, commandTimeoutMinutes: null, allowedModes: null }, + }); + expect(editor.issues.value).toEqual({ + maxConcurrentTasks: [{ text: 'The plan allows at most 4.' }], + }); + }); +}); + +describe('useSettings', () => { + it('clears the view when a later read is refused, so nothing stale reads as permission', async () => { + const read = vi + .fn<() => Promise>() + .mockResolvedValueOnce(view()) + .mockRejectedValueOnce(new ORPCError('FORBIDDEN')); + const settings = useSettings(read); + + await settings.refresh(); + expect(settings.view.value?.edition).toBe('self-hosted'); + + await settings.refresh(); + expect(settings.view.value).toBeNull(); + expect(settings.error.value).toBe('forbidden'); + }); +}); diff --git a/apps/web/modules/settings/composables/useSettingsForm.ts b/apps/web/modules/settings/composables/useSettingsForm.ts new file mode 100644 index 0000000..e1e3789 --- /dev/null +++ b/apps/web/modules/settings/composables/useSettingsForm.ts @@ -0,0 +1,97 @@ +import { computed, shallowRef, watch, type Ref } from 'vue'; + +import { + classifySettingsError, + issuesFromError, + type FieldIssues, + type FormResult, + type ServerIssue, + type SettingsError, +} from '../utils/form'; + +/** Where a save stands. `saved` is only reported while the form still matches what was saved. */ +export type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'; + +export interface SettingsFormOptions { + /** + * The draft, created by the caller with `ref`, so its type is the caller's concrete form rather + * than this function's generic one, and every control bound to it is deeply reactive. + */ + form: Ref
; + /** The form the current server state fills in; read again whenever that state changes. */ + initial: () => Form; + /** The input the form describes, or the fields that cannot be sent at all. */ + build: (form: Form) => FormResult; + /** Server issues by the control each one belongs to. */ + fieldIssues: (issues: readonly ServerIssue[]) => FieldIssues; + save: (input: Input) => Promise; + /** Receives what the server stored, so the page shows its answer rather than the draft. */ + onSaved: (result: Result) => void; +} + +/** + * A settings form: a draft, the server's verdict on it, and nothing decided in between. + * + * The draft starts from the server's state and starts over whenever that state changes, so a + * refresh or a save never leaves a form describing settings that are no longer there. Issues come + * back per field from the same parser the deployment file goes through; the form never decides a + * value is acceptable on its own, it only declines to send one it cannot express. + */ +export function useSettingsForm( + options: SettingsFormOptions, +) { + const { form } = options; + const baseline = shallowRef(snapshot(form.value)); + const issues = shallowRef>({}); + const status = shallowRef('idle'); + const error = shallowRef(null); + + watch(options.initial, (next) => { + form.value = next; + baseline.value = snapshot(next); + issues.value = {}; + }); + + const dirty = computed(() => snapshot(form.value) !== baseline.value); + const saving = computed(() => status.value === 'saving'); + const saved = computed(() => status.value === 'saved' && !dirty.value); + + async function submit(): Promise { + if (saving.value) return; + const built = options.build(form.value); + if (!built.ok) { + issues.value = built.issues; + status.value = 'error'; + error.value = 'invalid'; + return; + } + status.value = 'saving'; + issues.value = {}; + error.value = null; + try { + const result = await options.save(built.input); + status.value = 'saved'; + options.onSaved(result); + } catch (caught) { + const server = issuesFromError(caught); + issues.value = server ? options.fieldIssues(server) : {}; + error.value = classifySettingsError(caught); + status.value = 'error'; + } + } + + /** Discards the draft and its issues, back to what the server holds. */ + function reset(): void { + form.value = options.initial(); + baseline.value = snapshot(form.value); + issues.value = {}; + error.value = null; + status.value = 'idle'; + } + + return { form, issues, status, error, dirty, saving, saved, submit, reset }; +} + +function snapshot(value: unknown): string { + return JSON.stringify(value); +} diff --git a/apps/web/modules/settings/index.ts b/apps/web/modules/settings/index.ts new file mode 100644 index 0000000..c2a9b81 --- /dev/null +++ b/apps/web/modules/settings/index.ts @@ -0,0 +1,20 @@ +import { addComponentsDir, addImportsDir, createResolver, defineNuxtModule } from 'nuxt/kit'; + +/** + * Registers `settings/components` and `settings/composables`. Prefixed like the audit module: + * names like Form and Services are generic enough to collide with the next feature module. + * + * `utils/` is deliberately not auto-imported: it is the form's mapping to and from the API, and + * every caller imports it by name so the mapping stays one visible dependency. + */ +export default defineNuxtModule({ + meta: { + name: 'dashboard-settings', + }, + setup() { + const resolver = createResolver(import.meta.url); + + addComponentsDir({ path: resolver.resolve('./components'), prefix: 'Settings' }); + addImportsDir(resolver.resolve('./composables')); + }, +}); diff --git a/apps/web/modules/settings/utils/form.test.ts b/apps/web/modules/settings/utils/form.test.ts new file mode 100644 index 0000000..90bb20e --- /dev/null +++ b/apps/web/modules/settings/utils/form.test.ts @@ -0,0 +1,316 @@ +import type { OrganizationSettingsView } from '@code-zero/api'; +import type { DeploymentSettings } from '@code-zero/config'; +import { ORPCError } from '@orpc/client'; +import { describe, expect, it } from 'vitest'; + +import { + classifySettingsError, + DEFAULT_SETTINGS, + formFromSettings, + formFromView, + issuesFromError, + organizationFieldIssues, + organizationFormFromView, + organizationInputFromForm, + selectableModes, + settingsFieldIssues, + settingsInputFromForm, + type SelfHostedSettingsView, +} from './form.js'; + +const SETTINGS: DeploymentSettings = { + execution: { + maxConcurrentTasks: 8, + commandTimeoutMinutes: 20, + allowedModes: ['observe', 'suggest'], + maxNetwork: 'restricted', + }, + model: { provider: 'anthropic', name: 'claude-sonnet' }, + runner: { isolation: 'container', engine: 'podman', image: 'ghcr.io/acme/runner:1' }, +}; + +const BOUNDS = { + maxConcurrentTasks: { minimum: 1, maximum: 64 }, + commandTimeoutMinutes: { minimum: 1, maximum: 1440 }, +} as const; + +function selfHosted(state: SelfHostedSettingsView['state']): SelfHostedSettingsView { + return { + edition: 'self-hosted', + source: 'dashboard', + editable: true, + filePath: null, + state, + services: [], + bounds: BOUNDS, + }; +} + +function organization(limits: OrganizationSettingsView['limits']): OrganizationSettingsView { + return { id: 'org_1', role: 'owner', editable: true, limits, invalid: false, effective: null }; +} + +describe('the deployment settings form', () => { + it('sends back exactly the settings it was filled from', () => { + const result = settingsInputFromForm(formFromSettings(SETTINGS)); + + expect(result).toEqual({ + ok: true, + input: { + execution: { + maxConcurrentTasks: 8, + commandTimeoutMinutes: 20, + allowedModes: ['observe', 'suggest'], + maxNetwork: 'restricted', + }, + model: { provider: 'anthropic', name: 'claude-sonnet' }, + runner: { isolation: 'container', engine: 'podman', image: 'ghcr.io/acme/runner:1' }, + }, + }); + }); + + it('sends no model and no runner when each repository chooses its own', () => { + const form = formFromSettings(SETTINGS); + form.modelSource = 'repository'; + form.runnerSource = 'repository'; + + const result = settingsInputFromForm(form); + + expect(result.ok && result.input.model).toBeNull(); + expect(result.ok && result.input.runner).toBeNull(); + }); + + it('sends modes in the canonical order, whatever order they were ticked in', () => { + const form = formFromSettings(SETTINGS); + form.allowedModes = ['fix', 'observe', 'suggest']; + + const result = settingsInputFromForm(form); + + expect(result.ok && result.input.execution.allowedModes).toEqual(['observe', 'suggest', 'fix']); + }); + + it('trims the model name and the image before sending them', () => { + const form = formFromSettings(SETTINGS); + form.modelName = ' gpt-5 '; + form.runnerImage = ' ghcr.io/acme/runner:2\n'; + + const result = settingsInputFromForm(form); + + expect(result.ok && result.input.model?.name).toBe('gpt-5'); + expect(result.ok && result.input.runner?.image).toBe('ghcr.io/acme/runner:2'); + }); + + it('accepts the numbers Vue casts a number input to, as well as text', () => { + const form = formFromSettings(SETTINGS); + form.maxConcurrentTasks = 12; + form.commandTimeoutMinutes = '45'; + + const result = settingsInputFromForm(form); + + expect(result.ok && result.input.execution.maxConcurrentTasks).toBe(12); + expect(result.ok && result.input.execution.commandTimeoutMinutes).toBe(45); + }); + + it('refuses to send an emptied number, naming the field', () => { + const form = formFromSettings(SETTINGS); + form.commandTimeoutMinutes = ''; + + expect(settingsInputFromForm(form)).toEqual({ + ok: false, + issues: { commandTimeoutMinutes: [{ key: 'dashboard.settings.form.numberRequired' }] }, + }); + }); + + it('leaves the bounds to the server: an out-of-range value is sent as typed', () => { + const form = formFromSettings(SETTINGS); + form.maxConcurrentTasks = '9000'; + + const result = settingsInputFromForm(form); + + expect(result.ok && result.input.execution.maxConcurrentTasks).toBe(9000); + }); + + it("starts from the parser's defaults when the stored settings are invalid", () => { + const form = formFromView( + selfHosted({ ok: false, issues: [{ path: '$', message: 'Broken.' }] }), + ); + + expect(form).toEqual(formFromSettings(DEFAULT_SETTINGS)); + }); +}); + +describe('server issues', () => { + it('lands each issue on the control its path names', () => { + const issues = settingsFieldIssues([ + { + path: '$.execution.command_timeout_minutes', + message: 'Expected an integer from 1 to 1440.', + }, + { path: '$.execution.allowed_modes', message: 'Expected a list that includes observe.' }, + { path: '$.model.name', message: 'Looks like a credential.' }, + { path: '$.model', message: 'A cloud deployment must configure the model.' }, + { path: '$.runner.image', message: 'Expected a container image reference.' }, + ]); + + expect(issues).toEqual({ + commandTimeoutMinutes: [{ text: 'Expected an integer from 1 to 1440.' }], + allowedModes: [{ text: 'Expected a list that includes observe.' }], + modelName: [{ text: 'Looks like a credential.' }], + modelSource: [{ text: 'A cloud deployment must configure the model.' }], + runnerImage: [{ text: 'Expected a container image reference.' }], + }); + }); + + it('keeps an issue no control owns at the form level rather than dropping it', () => { + const issues = settingsFieldIssues([ + { path: '$.execution.max_concurrent_tasks_typo', message: 'Unknown setting.' }, + { path: '$', message: 'Expected an object.' }, + ]); + + expect(issues).toEqual({ + form: [{ text: 'Unknown setting.' }, { text: 'Expected an object.' }], + }); + }); + + it("reads the organization limits' paths from the limits document's own root", () => { + expect( + organizationFieldIssues([ + { path: '$.command_timeout_minutes', message: 'The plan allows at most 60.' }, + { path: '$.allowed_modes', message: 'The plan does not allow fix.' }, + ]), + ).toEqual({ + commandTimeoutMinutes: [{ text: 'The plan allows at most 60.' }], + allowedModes: [{ text: 'The plan does not allow fix.' }], + }); + }); + + it("reads issues out of a BAD_REQUEST's data, skipping entries of any other shape", () => { + const refusal = new ORPCError('BAD_REQUEST', { + data: { + issues: [ + { path: '$.model.name', message: 'Expected a model name.' }, + { message: 'Input validation failed', path: ['execution', 0] }, + 'not an issue', + ], + }, + }); + + expect(issuesFromError(refusal)).toEqual([ + { path: '$.model.name', message: 'Expected a model name.' }, + { path: '$', message: 'Input validation failed' }, + ]); + }); + + it('finds no value issues in a refusal that was not about the values', () => { + expect(issuesFromError(new ORPCError('FORBIDDEN'))).toBeNull(); + expect(issuesFromError(new Error('network'))).toBeNull(); + }); + + it('classifies a failure by its code, never by its message', () => { + expect(classifySettingsError(new ORPCError('FORBIDDEN', { message: 'anything' }))).toBe( + 'forbidden', + ); + expect(classifySettingsError(new ORPCError('UNAUTHORIZED'))).toBe('unauthorized'); + expect(classifySettingsError(new ORPCError('NOT_IMPLEMENTED'))).toBe('unavailable'); + expect(classifySettingsError(new ORPCError('BAD_REQUEST'))).toBe('invalid'); + expect(classifySettingsError(new ORPCError('PRECONDITION_FAILED'))).toBe('precondition'); + expect(classifySettingsError(new ORPCError('INTERNAL_SERVER_ERROR'))).toBe('generic'); + expect(classifySettingsError(new TypeError('fetch failed'))).toBe('generic'); + }); +}); + +describe('the organization limits form', () => { + it('sends null for every limit left to the plan', () => { + const form = organizationFormFromView( + organization({ maxConcurrentTasks: null, commandTimeoutMinutes: null, allowedModes: null }), + null, + ); + + expect(organizationInputFromForm(form)).toEqual({ + ok: true, + input: { maxConcurrentTasks: null, commandTimeoutMinutes: null, allowedModes: null }, + }); + }); + + it('sends the limits an organization chose, modes in the canonical order', () => { + const form = organizationFormFromView( + organization({ maxConcurrentTasks: 2, commandTimeoutMinutes: 10, allowedModes: ['observe'] }), + null, + ); + form.allowedModes = ['suggest', 'observe']; + + expect(organizationInputFromForm(form)).toEqual({ + ok: true, + input: { + maxConcurrentTasks: 2, + commandTimeoutMinutes: 10, + allowedModes: ['observe', 'suggest'], + }, + }); + }); + + it('hands the modes back to the plan when they are no longer restricted', () => { + const form = organizationFormFromView( + organization({ + maxConcurrentTasks: null, + commandTimeoutMinutes: null, + allowedModes: ['observe'], + }), + null, + ); + form.restrictModes = false; + + const result = organizationInputFromForm(form); + + expect(result.ok && result.input.allowedModes).toBeNull(); + }); + + it('never sends a mode the plan does not allow, since the form shows no box for one', () => { + const plan = { + maxConcurrentTasks: 6, + commandTimeoutMinutes: 20, + allowedModes: ['observe', 'suggest', 'fix'], + maxNetwork: 'restricted', + } as const; + const form = organizationFormFromView( + organization({ maxConcurrentTasks: null, commandTimeoutMinutes: null, allowedModes: null }), + plan, + ); + + // Restrict, then untick fix: what is left is what the reader sees ticked, and nothing else. + form.restrictModes = true; + form.allowedModes = form.allowedModes.filter((mode) => mode !== 'fix'); + + const result = organizationInputFromForm(form); + expect(result.ok && result.input.allowedModes).toEqual(['observe', 'suggest']); + }); + + it('drops a stored mode the plan has since stopped allowing from the draft', () => { + const form = organizationFormFromView( + organization({ + maxConcurrentTasks: null, + commandTimeoutMinutes: null, + allowedModes: ['observe', 'autonomous'], + }), + { + maxConcurrentTasks: 6, + commandTimeoutMinutes: 20, + allowedModes: ['observe'], + maxNetwork: 'none', + }, + ); + + expect(form.allowedModes).toEqual(['observe']); + }); + + it("offers only the plan's modes to narrow", () => { + expect( + selectableModes({ + maxConcurrentTasks: 4, + commandTimeoutMinutes: 30, + allowedModes: ['observe', 'fix'], + maxNetwork: 'none', + }), + ).toEqual(['observe', 'fix']); + }); +}); diff --git a/apps/web/modules/settings/utils/form.ts b/apps/web/modules/settings/utils/form.ts new file mode 100644 index 0000000..0629ce5 --- /dev/null +++ b/apps/web/modules/settings/utils/form.ts @@ -0,0 +1,378 @@ +// Types only: `@code-zero/config` reads files and parses YAML, so none of its code belongs in the +// browser bundle. The lists below restate its enumerations, and the server re-checks every value. +import type { OrganizationSettingsView, SettingsView } from '@code-zero/api'; +import type { DeploymentSettings, ExecutionSettings, OrganizationLimits } from '@code-zero/config'; +import type { ModelProviderKind, NetworkPolicy, RunMode } from '@code-zero/shared'; +import { ORPCError } from '@orpc/client'; + +export type SelfHostedSettingsView = Extract; +export type CloudSettingsView = Extract; +export type ContainerEngine = NonNullable['engine']; + +// Records rather than arrays, so adding a mode, a policy, an engine or a provider to the shared +// types fails the type check here until the form offers it too. +const MODE_SET: Record = { + observe: true, + suggest: true, + fix: true, + autonomous: true, +}; +const NETWORK_SET: Record = { none: true, restricted: true, full: true }; +const ENGINE_SET: Record = { docker: true, podman: true }; +const PROVIDER_SET: Record = { + 'ai-gateway': true, + anthropic: true, + 'claude-code': true, + 'codex-cli': true, + google: true, + openai: true, + 'openai-compatible': true, +}; + +/** In the canonical order the parser stores them in: least to most write access. */ +export const RUN_MODES = keysOf(MODE_SET); +/** Narrowest first. */ +export const NETWORK_POLICIES = keysOf(NETWORK_SET); +export const CONTAINER_ENGINES = keysOf(ENGINE_SET); +export const MODEL_PROVIDERS = keysOf(PROVIDER_SET); + +/** + * What the form starts from when the stored settings cannot be read: the parser's own defaults + * (`defaultDeploymentSettings` in `@code-zero/config`), so saving an untouched form changes nothing + * a deployment without settings did not already do. + */ +export const DEFAULT_SETTINGS: DeploymentSettings = { + execution: { + maxConcurrentTasks: 4, + commandTimeoutMinutes: 30, + allowedModes: RUN_MODES, + maxNetwork: 'full', + }, + model: null, + runner: null, +}; + +/** + * What a number input holds. Vue's `v-model` casts a `type="number"` input's value to a number and + * leaves it as text when it cannot (an emptied field is `''`), so both arrive here, and an emptied + * field is reported as empty rather than turned into a `0` the server would refuse for a reason + * the reader did not cause. + */ +export type NumberText = string | number; + +/** The editable settings, flat so every control binds one field and every issue names one. */ +export interface SettingsFormState { + maxConcurrentTasks: NumberText; + commandTimeoutMinutes: NumberText; + allowedModes: RunMode[]; + maxNetwork: NetworkPolicy; + /** `repository` leaves the model to each repository's `.code-zero.yml`. */ + modelSource: 'repository' | 'deployment'; + modelProvider: ModelProviderKind; + modelName: string; + /** `repository` leaves isolation to each repository's `.code-zero.yml`. */ + runnerSource: 'repository' | 'container'; + runnerEngine: ContainerEngine; + runnerImage: string; +} + +export type SettingsField = keyof SettingsFormState; + +/** An organization's own limits. An empty number, or unrestricted modes, means the plan's value. */ +export interface OrganizationFormState { + maxConcurrentTasks: NumberText; + commandTimeoutMinutes: NumberText; + restrictModes: boolean; + allowedModes: RunMode[]; +} + +export type OrganizationField = keyof OrganizationFormState; + +/** `settings.update`'s input, as `deploymentSettingsInput` in `@code-zero/api` declares it. */ +export interface SettingsInput { + execution: { + maxConcurrentTasks: number; + commandTimeoutMinutes: number; + allowedModes: string[]; + maxNetwork: string; + }; + model: { provider: string; name: string } | null; + runner: { isolation: string; engine: string; image: string } | null; +} + +/** The `limits` half of `settings.organization.update`'s input. `null` keeps the plan's value. */ +export interface OrganizationLimitsInput { + maxConcurrentTasks: number | null; + commandTimeoutMinutes: number | null; + allowedModes: string[] | null; +} + +/** + * One problem with one field: a key this app translates, or the server's own sentence. + * + * The server's sentences come from the settings parser and name the rule a value broke ("Expected + * an integer from 1 to 64."); they are rendered as text, never as markup. + */ +export type IssueText = { readonly key: string } | { readonly text: string }; + +/** Problems by field. `form` holds those that belong to no single control. */ +export type FieldIssues = Partial>; + +export type FormResult = + | { ok: true; input: Input } + | { ok: false; issues: FieldIssues }; + +/** An issue as the settings procedures report it in a `BAD_REQUEST`'s `data.issues`. */ +export interface ServerIssue { + path: string; + message: string; +} + +/** Why a settings call failed, in the terms the page has copy for. */ +export type SettingsError = + | 'forbidden' + | 'unauthorized' + | 'unavailable' + | 'invalid' + | 'precondition' + | 'generic'; + +/** Paths the settings parser reports, most specific first, and the control each belongs to. */ +const SETTINGS_PATHS: readonly (readonly [string, SettingsField])[] = [ + ['$.execution.max_concurrent_tasks', 'maxConcurrentTasks'], + ['$.execution.command_timeout_minutes', 'commandTimeoutMinutes'], + ['$.execution.allowed_modes', 'allowedModes'], + ['$.execution.max_network', 'maxNetwork'], + ['$.model.provider', 'modelProvider'], + ['$.model.name', 'modelName'], + ['$.model', 'modelSource'], + ['$.runner.engine', 'runnerEngine'], + ['$.runner.image', 'runnerImage'], + ['$.runner', 'runnerSource'], +]; + +/** The organization limits parser reports paths from the limits document's own root. */ +const ORGANIZATION_PATHS: readonly (readonly [string, OrganizationField])[] = [ + ['$.max_concurrent_tasks', 'maxConcurrentTasks'], + ['$.command_timeout_minutes', 'commandTimeoutMinutes'], + ['$.allowed_modes', 'allowedModes'], +]; + +const NUMBER_REQUIRED: IssueText = { key: 'dashboard.settings.form.numberRequired' }; + +/** The form a set of settings fills in. */ +export function formFromSettings(settings: DeploymentSettings): SettingsFormState { + return { + maxConcurrentTasks: String(settings.execution.maxConcurrentTasks), + commandTimeoutMinutes: String(settings.execution.commandTimeoutMinutes), + allowedModes: [...settings.execution.allowedModes], + maxNetwork: settings.execution.maxNetwork, + modelSource: settings.model ? 'deployment' : 'repository', + modelProvider: settings.model?.provider ?? 'openai', + modelName: settings.model?.name ?? '', + runnerSource: settings.runner ? 'container' : 'repository', + runnerEngine: settings.runner?.engine ?? 'docker', + runnerImage: settings.runner?.image ?? '', + }; +} + +/** The form for a self-hosted view: its settings, or the defaults while they are invalid. */ +export function formFromView(view: SelfHostedSettingsView): SettingsFormState { + return formFromSettings(view.state.ok ? view.state.settings : DEFAULT_SETTINGS); +} + +/** + * The `settings.update` input a form describes. + * + * Only what cannot be sent at all is caught here — an emptied number. Bounds, names and images + * are the server's to judge, with the same parser the deployment file goes through, so the form + * and the file cannot come to disagree about what is allowed. + */ +export function settingsInputFromForm( + form: SettingsFormState, +): FormResult { + const issues: FieldIssues = {}; + const maxConcurrentTasks = requiredNumber(form.maxConcurrentTasks); + const commandTimeoutMinutes = requiredNumber(form.commandTimeoutMinutes); + if (maxConcurrentTasks === null) issues.maxConcurrentTasks = [NUMBER_REQUIRED]; + if (commandTimeoutMinutes === null) issues.commandTimeoutMinutes = [NUMBER_REQUIRED]; + if (maxConcurrentTasks === null || commandTimeoutMinutes === null) return { ok: false, issues }; + return { + ok: true, + input: { + execution: { + maxConcurrentTasks, + commandTimeoutMinutes, + // Sent in the canonical order whatever order the boxes were ticked in. + allowedModes: RUN_MODES.filter((mode) => form.allowedModes.includes(mode)), + maxNetwork: form.maxNetwork, + }, + model: + form.modelSource === 'deployment' + ? { provider: form.modelProvider, name: form.modelName.trim() } + : null, + runner: + form.runnerSource === 'container' + ? { isolation: 'container', engine: form.runnerEngine, image: form.runnerImage.trim() } + : null, + }, + }; +} + +/** The form an organization's stored limits fill in. */ +export function organizationFormFromView( + view: OrganizationSettingsView, + plan: ExecutionSettings | null, +): OrganizationFormState { + return organizationFormFromLimits(view.limits, plan); +} + +/** + * The draft's modes are only ever the plan's: the form shows a box for each of those and no + * other, so a mode the plan does not allow — the full list while the plan's own modes apply, or a + * stored mode the plan has since dropped — would otherwise be sent without the reader seeing it. + */ +export function organizationFormFromLimits( + limits: OrganizationLimits, + plan: ExecutionSettings | null, +): OrganizationFormState { + const offered = selectableModes(plan); + return { + maxConcurrentTasks: limits.maxConcurrentTasks === null ? '' : String(limits.maxConcurrentTasks), + commandTimeoutMinutes: + limits.commandTimeoutMinutes === null ? '' : String(limits.commandTimeoutMinutes), + restrictModes: limits.allowedModes !== null, + allowedModes: limits.allowedModes + ? offered.filter((mode) => limits.allowedModes?.includes(mode)) + : offered, + }; +} + +/** + * The organization limits a form describes. An empty number keeps the plan's value, so the only + * local mistake left is text that is not a number at all. + */ +export function organizationInputFromForm( + form: OrganizationFormState, +): FormResult { + const issues: FieldIssues = {}; + const maxConcurrentTasks = optionalNumber(form.maxConcurrentTasks); + const commandTimeoutMinutes = optionalNumber(form.commandTimeoutMinutes); + if (maxConcurrentTasks === undefined) issues.maxConcurrentTasks = [NUMBER_REQUIRED]; + if (commandTimeoutMinutes === undefined) issues.commandTimeoutMinutes = [NUMBER_REQUIRED]; + if (maxConcurrentTasks === undefined || commandTimeoutMinutes === undefined) + return { ok: false, issues }; + return { + ok: true, + input: { + maxConcurrentTasks, + commandTimeoutMinutes, + allowedModes: form.restrictModes + ? RUN_MODES.filter((mode) => form.allowedModes.includes(mode)) + : null, + }, + }; +} + +/** Server issues for the deployment settings form, by the control each one belongs to. */ +export function settingsFieldIssues(issues: readonly ServerIssue[]): FieldIssues { + return byField(issues, SETTINGS_PATHS); +} + +/** Server issues for the organization limits form, by the control each one belongs to. */ +export function organizationFieldIssues( + issues: readonly ServerIssue[], +): FieldIssues { + return byField(issues, ORGANIZATION_PATHS); +} + +/** + * The issues a refused save carried, or null when the refusal was not about the values. + * + * Read defensively: the shape is the settings procedures' own, but anything that does not match it + * (the transport's input validation, say) still reaches the reader as a form-level issue rather + * than vanishing. + */ +export function issuesFromError(caught: unknown): ServerIssue[] | null { + if (!(caught instanceof ORPCError) || caught.code !== 'BAD_REQUEST') return null; + const data: unknown = caught.data; + const list = isRecord(data) && Array.isArray(data.issues) ? (data.issues as unknown[]) : []; + const issues = list.flatMap((issue): ServerIssue[] => + isRecord(issue) && typeof issue.message === 'string' + ? [{ path: typeof issue.path === 'string' ? issue.path : '$', message: issue.message }] + : [], + ); + return issues; +} + +/** + * The error code decides the copy, never the server's message: a refusal's wording is the + * server's, and the page has its own sentence for each kind of refusal. + */ +export function classifySettingsError(caught: unknown): SettingsError { + if (!(caught instanceof ORPCError)) return 'generic'; + switch (caught.code) { + case 'FORBIDDEN': + return 'forbidden'; + case 'UNAUTHORIZED': + return 'unauthorized'; + case 'NOT_IMPLEMENTED': + return 'unavailable'; + case 'BAD_REQUEST': + return 'invalid'; + case 'PRECONDITION_FAILED': + return 'precondition'; + default: + return 'generic'; + } +} + +/** Whether a mode is the one every deployment and organization must keep. */ +export function isRequiredMode(mode: RunMode): boolean { + return mode === 'observe'; +} + +/** The plan's modes an organization may choose from: it can narrow them, never add to them. */ +export function selectableModes(plan: ExecutionSettings | null): RunMode[] { + return plan ? RUN_MODES.filter((mode) => plan.allowedModes.includes(mode)) : [...RUN_MODES]; +} + +/** A set's keys, in the order they were written. */ +function keysOf(set: Readonly>): Key[] { + return Object.keys(set).filter((key): key is Key => key in set); +} + +function byField( + issues: readonly ServerIssue[], + paths: readonly (readonly [string, Field])[], +): FieldIssues { + const grouped: FieldIssues = {}; + for (const issue of issues) { + const field = paths.find(([prefix]) => covers(prefix, issue.path))?.[1] ?? 'form'; + (grouped[field] ??= []).push({ text: issue.message }); + } + return grouped; +} + +function covers(prefix: string, path: string): boolean { + return path === prefix || path.startsWith(`${prefix}.`) || path.startsWith(`${prefix}[`); +} + +function requiredNumber(text: NumberText): number | null { + const trimmed = String(text).trim(); + if (trimmed === '') return null; + const value = Number(trimmed); + return Number.isFinite(value) ? value : null; +} + +/** `null` for an empty field, `undefined` for text that is not a number. */ +function optionalNumber(text: NumberText): number | null | undefined { + const trimmed = String(text).trim(); + if (trimmed === '') return null; + const value = Number(trimmed); + return Number.isFinite(value) ? value : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/apps/web/modules/settings/utils/labels.ts b/apps/web/modules/settings/utils/labels.ts new file mode 100644 index 0000000..3b0464f --- /dev/null +++ b/apps/web/modules/settings/utils/labels.ts @@ -0,0 +1,44 @@ +import type { ServiceName } from '@code-zero/api'; +import type { NetworkPolicy, RunMode } from '@code-zero/shared'; + +import type { SettingsError } from './form'; + +// Spelled out rather than interpolated, so `i18n:report` can verify every key exists. + +export const MODE_LABEL_KEYS: Readonly> = { + observe: 'dashboard.settings.modes.observe', + suggest: 'dashboard.settings.modes.suggest', + fix: 'dashboard.settings.modes.fix', + autonomous: 'dashboard.settings.modes.autonomous', +}; + +export const NETWORK_LABEL_KEYS: Readonly> = { + none: 'dashboard.settings.network.none', + restricted: 'dashboard.settings.network.restricted', + full: 'dashboard.settings.network.full', +}; + +export const SERVICE_LABEL_KEYS: Readonly> = { + model: 'dashboard.settings.services.model', + sandbox: 'dashboard.settings.services.sandbox', + database: 'dashboard.settings.services.database', + mail: 'dashboard.settings.services.mail', +}; + +/** Why a save was refused. */ +export const SAVE_ERROR_KEYS: Readonly> = { + forbidden: 'dashboard.settings.form.forbidden', + unauthorized: 'dashboard.settings.form.unauthorized', + unavailable: 'dashboard.settings.error.unavailable', + invalid: 'dashboard.settings.form.invalid', + precondition: 'dashboard.settings.form.precondition', + generic: 'dashboard.settings.form.generic', +}; + +/** An ISO timestamp as the dashboard prints times: UTC, to the second. */ +export function formatTimestamp(iso: string): string { + const date = new Date(iso); + return Number.isNaN(date.getTime()) + ? iso + : `${date.toISOString().slice(0, 19).replace('T', ' ')}Z`; +} diff --git a/apps/web/modules/shared/components/app/Sidebar.vue b/apps/web/modules/shared/components/app/Sidebar.vue index 3dd23fd..2905120 100644 --- a/apps/web/modules/shared/components/app/Sidebar.vue +++ b/apps/web/modules/shared/components/app/Sidebar.vue @@ -125,6 +125,9 @@ interface NavItem { const navItems: readonly NavItem[] = [ { key: 'control', labelKey: 'dashboard.nav.control', icon: 'lucide:layout-dashboard', to: '/' }, { key: 'audit', labelKey: 'dashboard.nav.audit', icon: 'lucide:scroll-text', to: '/audit' }, + // Listed for every signed-in account: what the page shows depends on the edition and the role, + // and it explains a refusal itself rather than the nav guessing who may open it. + { key: 'settings', labelKey: 'dashboard.nav.settings', icon: 'lucide:settings', to: '/settings' }, ]; function isActive(item: NavItem): boolean { diff --git a/apps/web/modules/shared/composables/useAppShortcuts.ts b/apps/web/modules/shared/composables/useAppShortcuts.ts index b837940..f7466cc 100644 --- a/apps/web/modules/shared/composables/useAppShortcuts.ts +++ b/apps/web/modules/shared/composables/useAppShortcuts.ts @@ -31,6 +31,7 @@ export interface AppShortcut { export const APP_SHORTCUTS: readonly AppShortcut[] = [ { chords: ['G', 'C'], labelKey: 'dashboard.shortcuts.control' }, { chords: ['G', 'A'], labelKey: 'dashboard.shortcuts.audit' }, + { chords: ['G', 'S'], labelKey: 'dashboard.shortcuts.settings' }, { chords: ['B'], labelKey: 'dashboard.shortcuts.sidebar' }, { chords: ['Shift+/'], labelKey: 'dashboard.shortcuts.help' }, { chords: ['R'], labelKey: 'dashboard.shortcuts.refresh' }, @@ -69,6 +70,7 @@ export function useAppShortcuts(): void { [ { sequence: ['G', 'C'], callback: () => void router.push('/') }, { sequence: ['G', 'A'], callback: () => void router.push('/audit') }, + { sequence: ['G', 'S'], callback: () => void router.push('/settings') }, ], notWhileDialogOpen, ); diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index 4086a2a..41e3882 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -227,6 +227,9 @@ export default defineNuxtConfig({ // A session is enough to reach the page; the admin role is enforced by the endpoint it reads // (the router's `audit.list`), so a non-admin sees the refusal rather than a redirect. '/audit': { appLayout: 'default', auth: { only: 'user' } }, + // Same reasoning: who may read or change the settings is the router's `settings.*` decision + // (admin self-hosted, membership and role on the managed cloud), shown on the page itself. + '/settings': { appLayout: 'default', auth: { only: 'user' } }, '/login': { auth: { only: 'guest' } }, '/signin': { auth: { only: 'guest' } }, '/organizations': { appLayout: 'default', auth: { only: 'user' } }, diff --git a/apps/web/test/nuxt/components/SettingsForm.spec.ts b/apps/web/test/nuxt/components/SettingsForm.spec.ts new file mode 100644 index 0000000..d7541e3 --- /dev/null +++ b/apps/web/test/nuxt/components/SettingsForm.spec.ts @@ -0,0 +1,100 @@ +import type { SettingsView } from '@code-zero/api'; +import { mountSuspended } from '@nuxt/test-utils/runtime'; +import { ORPCError } from '@orpc/client'; +import { flushPromises } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import SettingsForm from '~~/modules/settings/components/Form.vue'; +import type { SelfHostedSettingsView, SettingsInput } from '~~/modules/settings/utils/form'; + +/** + * The dashboard-source form: it edits, sends, and shows the server's verdict beside each field. + * The save is a prop, so these specs hand it a stub and the router is never reached. + */ +const VIEW: SelfHostedSettingsView = { + edition: 'self-hosted', + source: 'dashboard', + editable: true, + filePath: null, + state: { + ok: true, + settings: { + execution: { + maxConcurrentTasks: 4, + commandTimeoutMinutes: 30, + allowedModes: ['observe', 'suggest', 'fix', 'autonomous'], + maxNetwork: 'full', + }, + model: null, + runner: { isolation: 'container', engine: 'docker', image: 'ghcr.io/acme/runner:1' }, + }, + saved: { by: 'admin@example.com', at: '2026-10-18T10:00:00.000Z' }, + warnings: [], + }, + services: [], + bounds: { + maxConcurrentTasks: { minimum: 1, maximum: 64 }, + commandTimeoutMinutes: { minimum: 1, maximum: 1440 }, + }, +}; + +describe('SettingsForm', () => { + it('shows the server issues beside the fields they name, and keeps the draft', async () => { + const save = vi.fn<(input: SettingsInput) => Promise>().mockRejectedValue( + new ORPCError('BAD_REQUEST', { + data: { + issues: [ + { + path: '$.execution.command_timeout_minutes', + message: 'Expected an integer from 1 to 1440.', + }, + ], + }, + }), + ); + const wrapper = await mountSuspended(SettingsForm, { props: { view: VIEW, save } }); + + await wrapper.find('input[name="commandTimeoutMinutes"]').setValue('5000'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(save).toHaveBeenCalledOnce(); + expect(save.mock.calls[0]?.[0].execution.commandTimeoutMinutes).toBe(5000); + const timeout = wrapper.find('input[name="commandTimeoutMinutes"]'); + expect(timeout.attributes('aria-invalid')).toBe('true'); + expect(wrapper.text()).toContain('Expected an integer from 1 to 1440.'); + expect(wrapper.text()).toContain('Some values were refused.'); + expect((timeout.element as HTMLInputElement).value).toBe('5000'); + expect(wrapper.emitted('saved')).toBeUndefined(); + }); + + it('emits the stored view once the server accepts the save', async () => { + const save = vi.fn<(input: SettingsInput) => Promise>().mockResolvedValue(VIEW); + const wrapper = await mountSuspended(SettingsForm, { props: { view: VIEW, save } }); + + await wrapper.find('input[name="maxConcurrentTasks"]').setValue('2'); + await wrapper.find('form').trigger('submit'); + await flushPromises(); + + expect(save.mock.calls[0]?.[0]).toMatchObject({ + execution: { maxConcurrentTasks: 2 }, + runner: { isolation: 'container', engine: 'docker', image: 'ghcr.io/acme/runner:1' }, + }); + expect(wrapper.emitted('saved')?.[0]).toEqual([VIEW]); + }); + + it('keeps observe ticked and out of reach, since no deployment can drop it', async () => { + const wrapper = await mountSuspended(SettingsForm, { props: { view: VIEW, save: vi.fn() } }); + + const observe = wrapper.find('input[name="allowedModes"][value="observe"]'); + expect((observe.element as HTMLInputElement).checked).toBe(true); + expect(observe.attributes('disabled')).toBeDefined(); + }); + + it('offers the model fields only when one model is imposed on every run', async () => { + const wrapper = await mountSuspended(SettingsForm, { props: { view: VIEW, save: vi.fn() } }); + + expect(wrapper.find('input[name="modelName"]').exists()).toBe(false); + await wrapper.find('input[name="modelSource"][value="deployment"]').setValue(true); + expect(wrapper.find('input[name="modelName"]').exists()).toBe(true); + }); +}); diff --git a/apps/web/test/nuxt/pages/settings.spec.ts b/apps/web/test/nuxt/pages/settings.spec.ts new file mode 100644 index 0000000..24062bd --- /dev/null +++ b/apps/web/test/nuxt/pages/settings.spec.ts @@ -0,0 +1,192 @@ +import type { OrganizationSettingsView, SettingsView } from '@code-zero/api'; +import { mockNuxtImport, mountSuspended } from '@nuxt/test-utils/runtime'; +// Imported explicitly rather than relying on Nuxt auto-imports: the plain `tsc` pass over test/** +// does not see the generated auto-import declarations that `vue-tsc` does. +import { useState } from 'nuxt/app'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { nextTick, shallowRef } from 'vue'; + +import SettingsPage from '~/pages/(dashboard)/settings.vue'; + +/** + * The page renders whatever `settings.get` described, and nothing it decided for itself: which + * edition, which source, and who may edit are the server's answers. These specs replace the read + * with a fixed view and check each state renders as the controls it promises — and no others. + */ +const view = shallowRef(null); +const error = shallowRef(null); +const pending = shallowRef(false); +const refresh = vi.fn<() => Promise>(() => Promise.resolve()); + +mockNuxtImport('useSettings', () => () => ({ + view, + error, + pending, + refresh, + replace: (next: SettingsView) => { + view.value = next; + }, +})); + +const BOUNDS = { + maxConcurrentTasks: { minimum: 1, maximum: 64 }, + commandTimeoutMinutes: { minimum: 1, maximum: 1440 }, +} as const; + +const SERVICES = [ + { service: 'sandbox', configured: true }, + { service: 'database', configured: true }, + { service: 'mail', configured: false }, +] as const; + +function fileView(state: Extract['state']): SettingsView { + return { + edition: 'self-hosted', + source: 'file', + editable: false, + filePath: '/srv/code-zero/config.yml', + state, + services: SERVICES, + bounds: BOUNDS, + }; +} + +const PLAN = { + maxConcurrentTasks: 4, + commandTimeoutMinutes: 60, + allowedModes: ['observe', 'suggest', 'fix'], + maxNetwork: 'restricted', +} as const; + +function cloudView(member: OrganizationSettingsView | null): SettingsView { + return { + edition: 'cloud', + services: [{ service: 'model', configured: true }, ...SERVICES], + plan: PLAN, + organization: member, + bounds: BOUNDS, + }; +} + +function organization(editable: boolean): OrganizationSettingsView { + return { + id: 'org_acme', + role: editable ? 'owner' : 'member', + editable, + limits: { maxConcurrentTasks: 2, commandTimeoutMinutes: null, allowedModes: null }, + invalid: false, + effective: { ...PLAN, maxConcurrentTasks: 2 }, + }; +} + +describe('settings page', () => { + beforeEach(() => { + view.value = null; + error.value = null; + pending.value = false; + refresh.mockClear(); + useState<{ id: string } | null>('organizations:active').value = null; + }); + + it('shows file settings read-only, with the file to edit', async () => { + view.value = fileView({ + ok: true, + settings: { + execution: { ...PLAN, allowedModes: ['observe', 'suggest'] }, + model: { provider: 'anthropic', name: 'claude-sonnet' }, + runner: null, + }, + saved: null, + warnings: [], + }); + + const wrapper = await mountSuspended(SettingsPage); + + expect(wrapper.text()).toContain('/srv/code-zero/config.yml'); + expect(wrapper.text()).toContain('claude-sonnet'); + expect(wrapper.text()).toContain('Timeout per command'); + expect(wrapper.text()).toContain('60 min'); + // Read-only means no control that could even look like it saves something. + expect(wrapper.find('form').exists()).toBe(false); + expect(wrapper.findAll('input')).toHaveLength(0); + expect(wrapper.text()).toContain('Download config.yml'); + }); + + it('lists every issue when the file is invalid, and offers no export of it', async () => { + view.value = fileView({ + ok: false, + issues: [{ path: '$.execution.max_network', message: 'Expected none, restricted, or full.' }], + }); + + const wrapper = await mountSuspended(SettingsPage); + + expect(wrapper.text()).toContain('The settings are invalid'); + expect(wrapper.text()).toContain('$.execution.max_network'); + expect(wrapper.text()).toContain('Expected none, restricted, or full.'); + const download = wrapper.findAll('button').find((button) => button.text().includes('Download')); + expect(download?.attributes('disabled')).toBeDefined(); + }); + + it('explains a refusal instead of rendering an empty page', async () => { + error.value = 'forbidden'; + + const wrapper = await mountSuspended(SettingsPage); + + expect(wrapper.text()).toContain('requires the admin role'); + expect(wrapper.find('form').exists()).toBe(false); + }); + + it('lets an organization owner lower the limits, below the plan', async () => { + view.value = cloudView(organization(true)); + + const wrapper = await mountSuspended(SettingsPage); + + expect(wrapper.text()).toContain('Plan limits'); + expect(wrapper.find('form').exists()).toBe(true); + expect(wrapper.find('input[name="maxConcurrentTasks"]').attributes('placeholder')).toBe( + 'Plan: 4', + ); + // The service's own wiring is never a control on the cloud. + expect(wrapper.find('select[name="modelProvider"]').exists()).toBe(false); + expect(wrapper.find('input[name="runnerImage"]').exists()).toBe(false); + }); + + it('shows a member the limits without any control to change them', async () => { + view.value = cloudView(organization(false)); + + const wrapper = await mountSuspended(SettingsPage); + + expect(wrapper.find('form').exists()).toBe(false); + expect(wrapper.findAll('input')).toHaveLength(0); + expect(wrapper.text()).toContain( + 'Only an organization owner or admin can change these limits.', + ); + expect(wrapper.text()).toContain('Effective limits'); + }); + + it('asks for an organization when none is selected on the cloud', async () => { + view.value = cloudView(null); + + const wrapper = await mountSuspended(SettingsPage); + + expect(wrapper.text()).toContain('Select an organization to see its limits.'); + }); + + it('reads the settings again when the sidebar switches organization', async () => { + view.value = cloudView(null); + await mountSuspended(SettingsPage); + refresh.mockClear(); + + // The switcher selects an organization after mount, for a session that had none. + useState<{ id: string } | null>('organizations:active').value = { id: 'org_acme' }; + await nextTick(); + + // Every page still mounted by an earlier spec answers too, so the count is not asserted. + expect(refresh).toHaveBeenCalled(); + + refresh.mockClear(); + useState<{ id: string } | null>('organizations:active').value = { id: 'org_acme' }; + await nextTick(); + expect(refresh).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/uno.config.ts b/apps/web/uno.config.ts index 75a1b56..5ba7b91 100644 --- a/apps/web/uno.config.ts +++ b/apps/web/uno.config.ts @@ -14,5 +14,10 @@ export default defineCodeZeroUno({ 'focus-ring h-9 flex items-center justify-center border text-xs text-ink font-650 transition disabled:cursor-wait disabled:opacity-60', ], ['btn-link', 'focus-ring text-xs text-link'], + // A small, square tag for a value that is one of a few: a mode, a source, an edition. + [ + 'chip', + 'h-6 inline-flex items-center gap-1.5 border border-line bg-raised px-2 text-3xs text-ink font-650', + ], ], }); diff --git a/packages/i18n/locales/en/dashboard.json b/packages/i18n/locales/en/dashboard.json index 4cd942b..1ddac70 100644 --- a/packages/i18n/locales/en/dashboard.json +++ b/packages/i18n/locales/en/dashboard.json @@ -15,7 +15,8 @@ "control": "Control Plane", "audit": "Audit Log", "collapse": "Collapse", - "expand": "Expand" + "expand": "Expand", + "settings": "Settings" }, "metrics": { "aria": "Runner metrics", @@ -125,7 +126,8 @@ "filter": "Focus the table filter", "next": "Select the next task", "previous": "Select the previous task", - "more": "Load older audit entries" + "more": "Load older audit entries", + "settings": "Go to the settings" }, "newTask": { "title": "New task", @@ -147,6 +149,150 @@ "proactive": "Proactive diff review", "feedback": "Review feedback" } + }, + "settings": { + "header": { + "eyebrow": "Code Zero / Configuration", + "title": "Settings" + }, + "loading": "Loading the settings…", + "error": { + "title": "The settings could not be read", + "forbidden": "Managing the deployment settings requires the admin role. Ask an administrator for access.", + "unauthorized": "Your session has expired. Sign in again to see the settings.", + "unavailable": "This deployment keeps no settings.", + "generic": "The settings are temporarily unavailable. Try again in a moment.", + "retry": "Try again" + }, + "invalid": { + "title": "The settings are invalid", + "body": "No task can start until every issue below is fixed." + }, + "warnings": { + "title": "Warnings" + }, + "source": { + "title": "Source", + "edition": "Edition", + "editions": { + "selfHosted": "Self-hosted", + "cloud": "Managed cloud" + }, + "source": "Settings source", + "sources": { + "file": "File", + "dashboard": "Dashboard" + }, + "file": "File", + "saved": "Last saved", + "neverSaved": "Never saved: the defaults apply.", + "fileHint": "Read-only here. Edit this file on the server and restart Code Zero to apply a change.", + "dashboardHint": "Stored in the database and edited on this page. A saved change applies to the next task that starts.", + "cloudHint": "The managed service runs the AI provider, the sandbox, the database and mail. They cannot be changed here.", + "switchHint": "The source is chosen with CODE_ZERO_SETTINGS_SOURCE when the server starts." + }, + "sections": { + "execution": "Execution", + "model": "Model", + "runner": "Runner" + }, + "fields": { + "maxConcurrentTasks": "Max concurrent tasks", + "commandTimeoutMinutes": "Timeout per command", + "allowedModes": "Allowed modes", + "maxNetwork": "Max network access", + "modelSource": "Model choice", + "provider": "Provider", + "modelName": "Model name", + "runnerSource": "Isolation", + "engine": "Container engine", + "image": "Image" + }, + "hints": { + "maxConcurrentTasks": "Tasks this deployment runs at once; the rest wait in the queue. From {min} to {max}.", + "commandTimeoutMinutes": "Minutes before one repository command is stopped. Not a limit on a whole run, which can run several commands. From {min} to {max}.", + "allowedModes": "Observe is always allowed: it never writes.", + "maxNetwork": "The widest network access any run may have. A repository may narrow it, never widen it.", + "model": "The provider's credentials stay in the server environment.", + "runner": "Only container isolation can be enforced from here." + }, + "choices": { + "modelRepository": "Each repository chooses (.code-zero.yml)", + "modelDeployment": "One model for every run", + "runnerRepository": "Each repository chooses (.code-zero.yml)", + "runnerContainer": "Every run in a container" + }, + "placeholders": { + "modelName": "gpt-5", + "image": "ghcr.io/acme/runner:1.0" + }, + "values": { + "minutes": "{count} min", + "repositoryChooses": "Each repository chooses" + }, + "modes": { + "observe": "Observe", + "suggest": "Suggest", + "fix": "Fix", + "autonomous": "Autonomous" + }, + "network": { + "none": "None", + "restricted": "Restricted", + "full": "Full" + }, + "form": { + "save": "Save settings", + "saving": "Saving…", + "reset": "Discard changes", + "saved": "Settings saved. The next task that starts uses them.", + "numberRequired": "Enter a number.", + "invalid": "Some values were refused. Fix the marked fields and save again.", + "forbidden": "You are not allowed to change these settings.", + "unauthorized": "Your session has expired. Sign in again, then save.", + "precondition": "The service settings are invalid, so these limits cannot be saved right now.", + "generic": "The settings could not be saved. Try again in a moment." + }, + "services": { + "title": "Services", + "caption": "Only whether each service is configured. Credentials, hosts and providers are never shown.", + "model": "AI provider", + "sandbox": "Sandbox", + "database": "Database", + "mail": "Mail", + "configured": "Configured", + "missing": "Not configured" + }, + "export": { + "title": "Export", + "body": "The settings as the YAML config.yml would hold. Secrets are never included.", + "unavailable": "Fix the invalid settings to export them.", + "download": "Download config.yml", + "copy": "Copy YAML", + "copied": "Copied", + "copyFailed": "The YAML could not be copied. Use the download instead.", + "error": "The settings could not be exported. Try again in a moment." + }, + "plan": { + "title": "Plan limits", + "caption": "The ceiling for every organization on this plan.", + "unavailable": "The plan's limits are unavailable right now, so no task can start until the service is restored." + }, + "organization": { + "title": "Organization limits", + "caption": "Stricter limits for this organization's own tasks. They can never exceed the plan.", + "none": "Select an organization to see its limits.", + "invalid": "The stored limits are invalid, so this organization's tasks cannot start until they are saved again.", + "emptyUsesPlan": "Leave empty to use the plan's value.", + "planValue": "Plan: {value}", + "usesPlan": "Plan's value", + "restrictModes": "Allow fewer modes than the plan", + "readOnly": "Only an organization owner or admin can change these limits.", + "save": "Save limits", + "saved": "Limits saved. The next task this organization starts uses them.", + "effective": "Effective limits", + "effectiveCaption": "What this organization's tasks are held to: the plan, lowered by the limits above." + } } } } diff --git a/packages/i18n/locales/it/dashboard.json b/packages/i18n/locales/it/dashboard.json index 8ddf621..0d7cd60 100644 --- a/packages/i18n/locales/it/dashboard.json +++ b/packages/i18n/locales/it/dashboard.json @@ -15,7 +15,8 @@ "control": "Piano di controllo", "audit": "Registro di audit", "collapse": "Comprimi", - "expand": "Espandi" + "expand": "Espandi", + "settings": "Impostazioni" }, "metrics": { "aria": "Metriche runner", @@ -125,7 +126,8 @@ "filter": "Attiva il filtro della tabella", "next": "Seleziona il task successivo", "previous": "Seleziona il task precedente", - "more": "Carica voci di audit meno recenti" + "more": "Carica voci di audit meno recenti", + "settings": "Vai alle impostazioni" }, "newTask": { "title": "Nuovo task", @@ -147,6 +149,150 @@ "proactive": "Revisione proattiva del diff", "feedback": "Commento di revisione" } + }, + "settings": { + "header": { + "eyebrow": "Code Zero / Configurazione", + "title": "Impostazioni" + }, + "loading": "Caricamento delle impostazioni…", + "error": { + "title": "Impossibile leggere le impostazioni", + "forbidden": "Per gestire le impostazioni del deployment serve il ruolo admin. Chiedi l'accesso a un amministratore.", + "unauthorized": "La sessione è scaduta. Accedi di nuovo per vedere le impostazioni.", + "unavailable": "Questo deployment non conserva impostazioni.", + "generic": "Le impostazioni non sono disponibili al momento. Riprova tra poco.", + "retry": "Riprova" + }, + "invalid": { + "title": "Le impostazioni non sono valide", + "body": "Nessun task può partire finché non risolvi ogni problema qui sotto." + }, + "warnings": { + "title": "Avvisi" + }, + "source": { + "title": "Origine", + "edition": "Edizione", + "editions": { + "selfHosted": "Self-hosted", + "cloud": "Cloud gestito" + }, + "source": "Origine delle impostazioni", + "sources": { + "file": "File", + "dashboard": "Dashboard" + }, + "file": "File", + "saved": "Ultimo salvataggio", + "neverSaved": "Mai salvate: valgono i valori predefiniti.", + "fileHint": "Qui sono in sola lettura. Modifica questo file sul server e riavvia Code Zero per applicare una modifica.", + "dashboardHint": "Salvate nel database e modificate da questa pagina. Una modifica salvata vale dal prossimo task che parte.", + "cloudHint": "Il servizio gestito fornisce il provider AI, la sandbox, il database e la posta. Qui non si possono modificare.", + "switchHint": "L'origine si sceglie con CODE_ZERO_SETTINGS_SOURCE all'avvio del server." + }, + "sections": { + "execution": "Esecuzione", + "model": "Modello", + "runner": "Runner" + }, + "fields": { + "maxConcurrentTasks": "Task contemporanei massimi", + "commandTimeoutMinutes": "Timeout per comando", + "allowedModes": "Modalità consentite", + "maxNetwork": "Accesso di rete massimo", + "modelSource": "Scelta del modello", + "provider": "Provider", + "modelName": "Nome del modello", + "runnerSource": "Isolamento", + "engine": "Motore dei container", + "image": "Immagine" + }, + "hints": { + "maxConcurrentTasks": "Task eseguiti insieme da questo deployment; gli altri attendono in coda. Da {min} a {max}.", + "commandTimeoutMinutes": "Minuti dopo i quali un singolo comando del repository viene fermato. Non limita un'intera esecuzione, che può lanciare più comandi. Da {min} a {max}.", + "allowedModes": "Observe è sempre consentita: non scrive mai.", + "maxNetwork": "L'accesso di rete più ampio concesso a un'esecuzione. Un repository può restringerlo, mai allargarlo.", + "model": "Le credenziali del provider restano nell'ambiente del server.", + "runner": "Da qui si può imporre solo l'isolamento in container." + }, + "choices": { + "modelRepository": "Lo sceglie ogni repository (.code-zero.yml)", + "modelDeployment": "Un solo modello per ogni esecuzione", + "runnerRepository": "Lo sceglie ogni repository (.code-zero.yml)", + "runnerContainer": "Ogni esecuzione in un container" + }, + "placeholders": { + "modelName": "gpt-5", + "image": "ghcr.io/acme/runner:1.0" + }, + "values": { + "minutes": "{count} min", + "repositoryChooses": "Lo sceglie ogni repository" + }, + "modes": { + "observe": "Observe", + "suggest": "Suggest", + "fix": "Fix", + "autonomous": "Autonomous" + }, + "network": { + "none": "Nessuno", + "restricted": "Limitato", + "full": "Completo" + }, + "form": { + "save": "Salva impostazioni", + "saving": "Salvataggio…", + "reset": "Annulla modifiche", + "saved": "Impostazioni salvate. Il prossimo task che parte le usa.", + "numberRequired": "Inserisci un numero.", + "invalid": "Alcuni valori sono stati rifiutati. Correggi i campi segnalati e salva di nuovo.", + "forbidden": "Non hai i permessi per modificare queste impostazioni.", + "unauthorized": "La sessione è scaduta. Accedi di nuovo, poi salva.", + "precondition": "Le impostazioni del servizio non sono valide, quindi questi limiti non si possono salvare ora.", + "generic": "Impossibile salvare le impostazioni. Riprova tra poco." + }, + "services": { + "title": "Servizi", + "caption": "Solo se ogni servizio è configurato. Credenziali, host e provider non vengono mai mostrati.", + "model": "Provider AI", + "sandbox": "Sandbox", + "database": "Database", + "mail": "Posta", + "configured": "Configurato", + "missing": "Non configurato" + }, + "export": { + "title": "Esporta", + "body": "Le impostazioni come YAML, come le conterrebbe config.yml. I segreti non sono mai inclusi.", + "unavailable": "Correggi le impostazioni non valide per esportarle.", + "download": "Scarica config.yml", + "copy": "Copia YAML", + "copied": "Copiato", + "copyFailed": "Impossibile copiare lo YAML. Usa il download.", + "error": "Impossibile esportare le impostazioni. Riprova tra poco." + }, + "plan": { + "title": "Limiti del piano", + "caption": "Il tetto per ogni organizzazione su questo piano.", + "unavailable": "I limiti del piano non sono disponibili ora, quindi nessun task può partire finché il servizio non torna disponibile." + }, + "organization": { + "title": "Limiti dell'organizzazione", + "caption": "Limiti più severi per i task di questa organizzazione. Non possono mai superare il piano.", + "none": "Seleziona un'organizzazione per vederne i limiti.", + "invalid": "I limiti salvati non sono validi, quindi i task di questa organizzazione non possono partire finché non li salvi di nuovo.", + "emptyUsesPlan": "Lascia vuoto per usare il valore del piano.", + "planValue": "Piano: {value}", + "usesPlan": "Valore del piano", + "restrictModes": "Consenti meno modalità del piano", + "readOnly": "Solo un owner o un admin dell'organizzazione può modificare questi limiti.", + "save": "Salva limiti", + "saved": "Limiti salvati. Il prossimo task di questa organizzazione li usa.", + "effective": "Limiti effettivi", + "effectiveCaption": "I limiti applicati ai task di questa organizzazione: il piano, abbassato dai limiti qui sopra." + } } } } diff --git a/packages/i18n/schemas/dashboard.schema.json b/packages/i18n/schemas/dashboard.schema.json index 147d660..2b55d79 100644 --- a/packages/i18n/schemas/dashboard.schema.json +++ b/packages/i18n/schemas/dashboard.schema.json @@ -51,6 +51,9 @@ }, "expand": { "type": "string" + }, + "settings": { + "type": "string" } }, "additionalProperties": false @@ -381,6 +384,9 @@ }, "more": { "type": "string" + }, + "settings": { + "type": "string" } }, "additionalProperties": false @@ -447,6 +453,438 @@ } }, "additionalProperties": false + }, + "settings": { + "type": "object", + "properties": { + "header": { + "type": "object", + "properties": { + "eyebrow": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "additionalProperties": false + }, + "loading": { + "type": "string" + }, + "error": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "forbidden": { + "type": "string" + }, + "unauthorized": { + "type": "string" + }, + "unavailable": { + "type": "string" + }, + "generic": { + "type": "string" + }, + "retry": { + "type": "string" + } + }, + "additionalProperties": false + }, + "invalid": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "type": "string" + } + }, + "additionalProperties": false + }, + "warnings": { + "type": "object", + "properties": { + "title": { + "type": "string" + } + }, + "additionalProperties": false + }, + "source": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "edition": { + "type": "string" + }, + "editions": { + "type": "object", + "properties": { + "selfHosted": { + "type": "string" + }, + "cloud": { + "type": "string" + } + }, + "additionalProperties": false + }, + "source": { + "type": "string" + }, + "sources": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "dashboard": { + "type": "string" + } + }, + "additionalProperties": false + }, + "file": { + "type": "string" + }, + "saved": { + "type": "string" + }, + "neverSaved": { + "type": "string" + }, + "fileHint": { + "type": "string" + }, + "dashboardHint": { + "type": "string" + }, + "cloudHint": { + "type": "string" + }, + "switchHint": { + "type": "string" + } + }, + "additionalProperties": false + }, + "sections": { + "type": "object", + "properties": { + "execution": { + "type": "string" + }, + "model": { + "type": "string" + }, + "runner": { + "type": "string" + } + }, + "additionalProperties": false + }, + "fields": { + "type": "object", + "properties": { + "maxConcurrentTasks": { + "type": "string" + }, + "commandTimeoutMinutes": { + "type": "string" + }, + "allowedModes": { + "type": "string" + }, + "maxNetwork": { + "type": "string" + }, + "modelSource": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "modelName": { + "type": "string" + }, + "runnerSource": { + "type": "string" + }, + "engine": { + "type": "string" + }, + "image": { + "type": "string" + } + }, + "additionalProperties": false + }, + "hints": { + "type": "object", + "properties": { + "maxConcurrentTasks": { + "type": "string" + }, + "commandTimeoutMinutes": { + "type": "string" + }, + "allowedModes": { + "type": "string" + }, + "maxNetwork": { + "type": "string" + }, + "model": { + "type": "string" + }, + "runner": { + "type": "string" + } + }, + "additionalProperties": false + }, + "choices": { + "type": "object", + "properties": { + "modelRepository": { + "type": "string" + }, + "modelDeployment": { + "type": "string" + }, + "runnerRepository": { + "type": "string" + }, + "runnerContainer": { + "type": "string" + } + }, + "additionalProperties": false + }, + "placeholders": { + "type": "object", + "properties": { + "modelName": { + "type": "string" + }, + "image": { + "type": "string" + } + }, + "additionalProperties": false + }, + "values": { + "type": "object", + "properties": { + "minutes": { + "type": "string" + }, + "repositoryChooses": { + "type": "string" + } + }, + "additionalProperties": false + }, + "modes": { + "type": "object", + "properties": { + "observe": { + "type": "string" + }, + "suggest": { + "type": "string" + }, + "fix": { + "type": "string" + }, + "autonomous": { + "type": "string" + } + }, + "additionalProperties": false + }, + "network": { + "type": "object", + "properties": { + "none": { + "type": "string" + }, + "restricted": { + "type": "string" + }, + "full": { + "type": "string" + } + }, + "additionalProperties": false + }, + "form": { + "type": "object", + "properties": { + "save": { + "type": "string" + }, + "saving": { + "type": "string" + }, + "reset": { + "type": "string" + }, + "saved": { + "type": "string" + }, + "numberRequired": { + "type": "string" + }, + "invalid": { + "type": "string" + }, + "forbidden": { + "type": "string" + }, + "unauthorized": { + "type": "string" + }, + "precondition": { + "type": "string" + }, + "generic": { + "type": "string" + } + }, + "additionalProperties": false + }, + "services": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "caption": { + "type": "string" + }, + "model": { + "type": "string" + }, + "sandbox": { + "type": "string" + }, + "database": { + "type": "string" + }, + "mail": { + "type": "string" + }, + "configured": { + "type": "string" + }, + "missing": { + "type": "string" + } + }, + "additionalProperties": false + }, + "export": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "unavailable": { + "type": "string" + }, + "download": { + "type": "string" + }, + "copy": { + "type": "string" + }, + "copied": { + "type": "string" + }, + "copyFailed": { + "type": "string" + }, + "error": { + "type": "string" + } + }, + "additionalProperties": false + }, + "plan": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "caption": { + "type": "string" + }, + "unavailable": { + "type": "string" + } + }, + "additionalProperties": false + }, + "organization": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "caption": { + "type": "string" + }, + "none": { + "type": "string" + }, + "invalid": { + "type": "string" + }, + "emptyUsesPlan": { + "type": "string" + }, + "planValue": { + "type": "string" + }, + "usesPlan": { + "type": "string" + }, + "restrictModes": { + "type": "string" + }, + "readOnly": { + "type": "string" + }, + "save": { + "type": "string" + }, + "saved": { + "type": "string" + }, + "effective": { + "type": "string" + }, + "effectiveCaption": { + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } }, "additionalProperties": false From ec9e7d9faad84c771559876271cf94694c509ca1 Mon Sep 17 00:00:00 2001 From: RedStar Date: Mon, 19 Oct 2026 12:05:00 +0200 Subject: [PATCH 6/8] docs: document deployment settings, editions and config.yml Add a Deployment settings guide: editions, the two self-hosted sources, the config.yml format with a validated example, editing and export from the dashboard, what is locked on the managed cloud, the per-command timeout, and the controls that do not exist yet. Add the settings authority boundary to docs/architecture.md (included by the guide), CODE_ZERO_EDITION and CODE_ZERO_SETTINGS_SOURCE to the environment reference and apps/web/.env.example, and move references from code-zero.deployment.yml to config.yml. --- README.md | 8 +- .../1.guide/10.api/4.protect-endpoints.md | 2 +- apps/docs/content/1.guide/16.deployment.md | 3 +- apps/docs/content/1.guide/17.settings.md | 125 ++++++++++++++++++ .../1.guide/4.environment-variables.md | 19 ++- apps/web/.env.example | 23 +++- docs/architecture.md | 25 ++++ 7 files changed, 191 insertions(+), 14 deletions(-) create mode 100644 apps/docs/content/1.guide/17.settings.md diff --git a/README.md b/README.md index 190ec35..1ef5fb2 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ The CLI parses arguments with [`@bomb.sh/args`](https://github.com/bomb-sh/args) | `/api/v1/**` | The same router over OpenAPI/REST; interactive docs at `/api/v1/docs`, spec at `/api/v1/openapi.json` | | `/api/auth/**` | The Better Auth handler (mounted by `@onmax/nuxt-better-auth` from `server/auth.config.ts`) | -`/rpc/**` and `/api/v1/**` are the same `rpcRouter` from [`packages/api`](./packages/api) served over two wire protocols, so authorization behaves identically either way. Reads are open for the dashboard; mutations (`tasks.create`, `approvals.decide`) fail closed. `CODE_ZERO_CONTROL_PLANE_TOKENS` holds comma-separated `name:token` bearer credentials — the one half of this policy that is a secret, and so the one half still in the environment. Which execution modes each principal may request is `control_plane.modes` in `code-zero.deployment.yml`; without a grant a principal may only request the non-writable `observe` and `suggest` modes, so `fix` and `autonomous` require an explicit operator grant. Which checkouts `tasks.create` may target is neither a secret nor fixed for the life of the process, so it is the `repository` table an operator edits from the dashboard; with none configured every task creation is refused. The approval actor is the authenticated principal's name, never a wire-supplied value. This bearer-token scheme authorizes the control-plane API and is independent of the Better Auth session that protects the dashboard UI. +`/rpc/**` and `/api/v1/**` are the same `rpcRouter` from [`packages/api`](./packages/api) served over two wire protocols, so authorization behaves identically either way. Reads are open for the dashboard; mutations (`tasks.create`, `approvals.decide`) fail closed. `CODE_ZERO_CONTROL_PLANE_TOKENS` holds comma-separated `name:token` bearer credentials — the one half of this policy that is a secret, and so the one half still in the environment. Which execution modes each principal may request is `control_plane.modes` in the instance file, `config.yml`; without a grant a principal may only request the non-writable `observe` and `suggest` modes, so `fix` and `autonomous` require an explicit operator grant. Which checkouts `tasks.create` may target is neither a secret nor fixed for the life of the process, so it is the `repository` table an operator edits from the dashboard; with none configured every task creation is refused. The approval actor is the authenticated principal's name, never a wire-supplied value. This bearer-token scheme authorizes the control-plane API and is independent of the Better Auth session that protects the dashboard UI. Typed clients infer their shape from the router rather than redeclaring request and response types: @@ -421,7 +421,7 @@ also tagged with its full commit SHA, and `vX.Y.Z` tags matching the dashboard v docker build -t code-zero . docker run --rm -p 3000:3000 --env-file apps/web/.env \ -v code-zero-data:/app/.data \ - -v "$PWD/code-zero.deployment.yml:/app/code-zero.deployment.yml:ro" \ + -v "$PWD/config.yml:/app/config.yml:ro" \ code-zero ``` @@ -436,8 +436,8 @@ environment sets. Apply migrations against `DATABASE_URL` with root needs `RAILWAY_RUN_UID=0`, since the image runs as the unprivileged `node` user). The image carries only `.output/`, so the deployment policy has to be supplied: mount -`code-zero.deployment.yml` at `/app/code-zero.deployment.yml` as above, or mount it elsewhere and -point `CODE_ZERO_CONFIG` at it. Without it the process falls back to the closed defaults (no CORS +`config.yml` at `/app/config.yml` as above (see [`config.example.yml`](./config.example.yml)), or +mount it elsewhere and point `CODE_ZERO_CONFIG` at it. Without it the process falls back to the closed defaults (no CORS origins, no `fix` or `autonomous` grants). The image ships `git` for the host runner but no container engine, so `runner.isolation: container` is not supported by it: tasks that require container isolation fail closed instead of running on the host. Deploy the `.output/` bundle on a diff --git a/apps/docs/content/1.guide/10.api/4.protect-endpoints.md b/apps/docs/content/1.guide/10.api/4.protect-endpoints.md index 1d46b37..c40cf62 100644 --- a/apps/docs/content/1.guide/10.api/4.protect-endpoints.md +++ b/apps/docs/content/1.guide/10.api/4.protect-endpoints.md @@ -20,7 +20,7 @@ The authenticated principal's **name** (not the token) is what the system record ## Execution-mode grants -Which execution modes a principal may request is deployment policy, so it lives in `code-zero.deployment.yml` (or the path `CODE_ZERO_CONFIG` names) rather than in the environment beside the token itself: +Which execution modes a principal may request is deployment policy, so it lives in the instance file, `config.yml` (or the path `CODE_ZERO_CONFIG` names), rather than in the environment beside the token itself: ```yaml control_plane: diff --git a/apps/docs/content/1.guide/16.deployment.md b/apps/docs/content/1.guide/16.deployment.md index 747d25b..444160c 100644 --- a/apps/docs/content/1.guide/16.deployment.md +++ b/apps/docs/content/1.guide/16.deployment.md @@ -19,9 +19,10 @@ node apps/web/.output/server/index.mjs - [ ] **Postgres** — point `DATABASE_URL` at a managed database and apply the schema once with `aube run db:migrate`. See [Database](/guide/database). - [ ] **Auth policy** — decide `AUTH_ENABLE_SIGNUP`, the OAuth credentials, organization flags, and the mail-backed flows; they are read at server start, so a change needs a restart. Set `NUXT_PUBLIC_SITE_URL`, which passkeys are bound to. See [Authentication](/guide/authentication/overview). - [ ] **Mail** — configure Resend (`RESEND_API_KEY`; `MAIL_PROVIDER=resend` is optional) or set `MAIL_PROVIDER=smtp` with its SMTP variables. With neither, `console` only logs. Required if invitations or organizations are enabled. See [Mails](/guide/mails). -- [ ] **Control plane** — issue bearer tokens via `CODE_ZERO_CONTROL_PLANE_TOKENS`, add the repositories `tasks.create` may target from the dashboard (they are rows in the store), and grant modes via `control_plane.modes` in `code-zero.deployment.yml`. Without a token, mutations are rejected. See [Protect endpoints](/guide/api/protect-endpoints). +- [ ] **Control plane** — issue bearer tokens via `CODE_ZERO_CONTROL_PLANE_TOKENS`, add the repositories `tasks.create` may target from the dashboard (they are rows in the store), and grant modes via `control_plane.modes` in `config.yml`. Without a token, mutations are rejected. See [Protect endpoints](/guide/api/protect-endpoints). - [ ] **Webhooks** — set `GITHUB_WEBHOOK_SECRET` and `CODE_ZERO_CHECKOUT_PATH`; the webhook route fails closed (503) until both are set. Configure the webhook on the source-control side per [Source-control providers](/reference/source-control-providers). - [ ] **Isolated execution** — production runs that write require `runner.isolation: container` in repository policy, with an image and resource limits. The included `LocalRunner` is for trusted local development only. See the [Safety model](/guide/safety). +- [ ] **Deployment settings** — set `CODE_ZERO_EDITION` (`self-hosted` unless this is the managed service) and choose `CODE_ZERO_SETTINGS_SOURCE`: `file` reads the limits from `config.yml`, `dashboard` lets an admin edit them at `/settings`. Invalid settings refuse every run. See [Deployment settings](/guide/settings). - [ ] **Model provider** — select the provider in repository policy and set its credential environment variable. See [Model providers](/reference/model-providers). ## Task history storage diff --git a/apps/docs/content/1.guide/17.settings.md b/apps/docs/content/1.guide/17.settings.md new file mode 100644 index 0000000..83fd4cd --- /dev/null +++ b/apps/docs/content/1.guide/17.settings.md @@ -0,0 +1,125 @@ +--- +title: Deployment settings +--- + +Deployment settings are the limits every run on a deployment is held to: how many tasks run at once, how long one command may take, which execution modes may start, how much network a run gets, and, optionally, one model and one container sandbox for every repository. They apply on top of each repository's own [`.code-zero.yml`](/guide/configuration), and the stricter of the two always wins. + +The dashboard shows them at `/settings`. What that page lets you do depends on the edition and on where the settings come from. + +## Editions + +The edition is stated in the environment, never guessed from the host: + +| `CODE_ZERO_EDITION` | What it is | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `self-hosted` (default) | You run Code Zero. An administrator sees and, with the dashboard source, edits every setting. | +| `cloud` | The managed service. The service fixes the AI provider, sandbox, database, and mail; organizations may only lower their limits. | + +A self-hosted install on Cloudflare, Vercel, or any other host is still `self-hosted`. A value other than these two is treated as `cloud`, the locked edition, and every run is refused until it is corrected. + +## Choose where the settings come from + +A self-hosted deployment has exactly one source, chosen with `CODE_ZERO_SETTINGS_SOURCE` when the server starts: + +```bash +# The default: config.yml is the only authority, and the page is read-only. +CODE_ZERO_SETTINGS_SOURCE=file + +# Postgres is the only authority, and an administrator edits the settings on the page. +CODE_ZERO_SETTINGS_SOURCE=dashboard +``` + +| Source | Where the settings live | The settings page | +| ---------------- | --------------------------------------------------------------- | -------------------------------------------------------------------- | +| `file` (default) | The `execution`, `model`, and `runner` sections of `config.yml` | Read-only. Shows which file to edit. Edit it and restart the server. | +| `dashboard` | The `deployment_setting` table in Postgres | An administrator edits and saves them. They can be exported as YAML. | + +The two never merge. With the dashboard as the source, settings sections still present in `config.yml` are ignored, and the page warns about them. Moving between sources is a restart with a different `CODE_ZERO_SETTINGS_SOURCE`, never something a request can do. To move from the dashboard to the file, export the settings first and paste them into `config.yml`. + +Only an account with the admin role can open the page on a self-hosted deployment; anyone else is told so. Reading and saving need a signed-in session: API keys and operator tokens cannot read or change the settings. + +## The instance file: config.yml + +Code Zero reads `config.yml` from the server's working directory, or the file `CODE_ZERO_CONFIG` names: + +```bash +CODE_ZERO_CONFIG=/etc/code-zero/config.yml +``` + +The old name `code-zero.deployment.yml` is still read when it is the only file present, with a warning to rename it. When both names exist and `CODE_ZERO_CONFIG` names neither, nothing is read and the settings are refused until you keep one. + +`config.yml` also holds the control-plane and polling policy (`control_plane`, `poll`); those are read whichever source the settings use. The annotated template is [`config.example.yml`](https://github.com/wolfstar-project/code-zero/blob/main/config.example.yml). A complete settings example: + +```yaml +execution: + # Tasks this deployment runs at once; the rest wait in the queue. 1 to 64. + max_concurrent_tasks: 4 + # Minutes before one repository command is stopped. 1 to 1440. + command_timeout_minutes: 30 + # Modes a run may start in. Must include observe. + allowed_modes: [observe, suggest, fix] + # The widest network any run may have: none, restricted, or full. + max_network: restricted + +# Optional. Replaces each repository's model choice. The credential stays in the environment. +model: + provider: anthropic + name: claude-sonnet-4-5 + +# Optional. Forces every run this process executes into a container. +runner: + isolation: container + engine: docker + image: ghcr.io/acme/code-zero-runner:1.0 +``` + +| Key | Default | Effect on a run | +| ----------------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------ | +| `execution.max_concurrent_tasks` | `4` | The scheduler's ceiling for the whole deployment. | +| `execution.command_timeout_minutes` | `30` | Caps the repository's own command timeout. See [below](#the-timeout-is-per-command). | +| `execution.allowed_modes` | `[observe, suggest, fix, autonomous]` | A run in any other mode is refused before it is recorded. | +| `execution.max_network` | `full` | A ceiling: a repository may narrow it, never widen it. | +| `model` | none: each repository chooses | `provider` and `name` replace the repository's model. | +| `runner` | none: each repository chooses | Forces container isolation with this `engine` (`docker` or `podman`) and `image`. | + +The settings are strict. An unknown key, an out-of-range number, a value that looks like a credential, or YAML that does not parse makes the whole document invalid, with no field quietly falling back to a default. Invalid settings fail closed: no task starts, from the dashboard, a webhook, or the poller, until they are fixed, and the settings page lists every issue with its `$.path`. `observe` cannot be removed from `allowed_modes`, because it is the mode every unrequested run starts in and the one that never writes. `runner` accepts only container isolation: a setting that moved a repository onto the host would be the one place a run ended up less isolated than its own policy asked for. + +## Edit the settings from the dashboard + +With `CODE_ZERO_SETTINGS_SOURCE=dashboard`, an administrator edits the execution limits, the model, and the runner on the settings page. The inputs show the accepted ranges as hints, but the server checks every value with the same parser `config.yml` goes through; a refused value is reported beside its field. A saved change applies to the next task that starts, and the page shows who saved it and when. + +**Export** downloads the settings as `config.yml`, or copies them as YAML. The export never contains a secret: credentials stay in the environment, the settings refuse anything shaped like one, and the export is redacted against the environment's secrets on the way out. + +## Managed cloud + +On `cloud` the service's own `config.yml` is the only source, and `CODE_ZERO_SETTINGS_SOURCE=dashboard` is refused. The AI provider, the runner and sandbox, the database and storage, and the mail transport are fixed by the service and are not editable. Customers see only whether each one is configured, never a credential, host, or provider name. + +The plan's limits are shown read-only and are the ceiling. An organization's owners and admins may set stricter limits for their own organization's tasks: + +- max concurrent tasks; +- timeout per command; +- allowed modes, chosen from the plan's. + +An empty field uses the plan's value. The server refuses any value above the plan, and takes the lower of the two again on every run, so a plan lowered later still wins. Members see their organization's limits and the effective result, with no control to change them. The organization is the one selected in the sidebar; its membership and role are checked against the database on every request. + +## The timeout is per command + +`command_timeout_minutes` is how long one repository command may run before it is stopped, and the longest a hosted sandbox lease lasts. It is not a limit on a whole run: a run that executes several commands, or waits on the model, can take longer in total. The page and the API name it accordingly. + +## Not available yet + +These are not settings today, and the page shows no control for them, because the server has nothing that would enforce one: + +- cost budgets, for a deployment, a plan, or an organization; +- a list of commands runs are allowed to execute; +- requiring an approval before a run writes (narrow `allowed_modes` instead); +- an organization-wide two-factor requirement; +- per-organization repository connections and triggers; +- personal notification preferences. + +## How it is enforced + + + + diff --git a/apps/docs/content/1.guide/4.environment-variables.md b/apps/docs/content/1.guide/4.environment-variables.md index 52f9ea0..1364f32 100644 --- a/apps/docs/content/1.guide/4.environment-variables.md +++ b/apps/docs/content/1.guide/4.environment-variables.md @@ -51,15 +51,26 @@ Each provider reads only its documented variable. See [Model providers](/referen The control-plane API (`/rpc/**` and `/api/v1/**`) fails closed: without `CODE_ZERO_CONTROL_PLANE_TOKENS` every mutation is rejected while reads stay open. -| Variable | Purpose | -| -------------------------------- | --------------------------------------------------------------------------------------------------- | -| `CODE_ZERO_CONTROL_PLANE_TOKENS` | Comma-separated `name:token` bearer credentials | -| `CODE_ZERO_CONFIG` | Path to the deployment policy file; defaults to `code-zero.deployment.yml` in the working directory | +| Variable | Purpose | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `CODE_ZERO_CONTROL_PLANE_TOKENS` | Comma-separated `name:token` bearer credentials | +| `CODE_ZERO_CONFIG` | Path to the instance file; defaults to `config.yml` in the working directory (`code-zero.deployment.yml` when only it exists) | The rest of the control plane's policy is not environment configuration: allowed CORS origins (`control_plane.origins`) and execution-mode grants (`control_plane.modes`) are read from that deployment file, and the repositories `tasks.create` may target are rows in the store rather than a variable. See [Protect endpoints](/guide/api/protect-endpoints) for how these are enforced. +## Deployment settings + +Two bootstrap values decide what the [settings page](/guide/settings) is and where the settings it shows come from. Both are read once at start; no request can change them. + +| Variable | Values | Purpose | +| --------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | +| `CODE_ZERO_EDITION` | `self-hosted` (default), `cloud` | Which product this deployment is. Never inferred from the host; an unrecognised value is treated as `cloud` and refuses every run. | +| `CODE_ZERO_SETTINGS_SOURCE` | `file` (default), `dashboard` | Self-hosted only: read the settings from `config.yml` (read-only on the page) or from Postgres (edited on the page). `cloud` needs `file`. | + +The settings themselves (concurrency, per-command timeout, allowed modes, network ceiling, model, runner) are not environment variables: they live in `config.yml` or in the store. See [Deployment settings](/guide/settings). + ## Webhooks `POST /webhooks/github` fails closed (503, nothing ingested) until both variables are set. diff --git a/apps/web/.env.example b/apps/web/.env.example index e136f9a..9e60967 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -27,14 +27,29 @@ GITHUB_TOKEN= # secret, which is why it is the only half still spelled here. # # What each principal may run, and which origins may read /api/v1/** cross-origin, are policy -# rather than secrets and live in `code-zero.deployment.yml` (see code-zero.deployment.example.yml). +# rather than secrets and live in `config.yml` (see config.example.yml at the repository root). # Which checkouts a task may target is neither: it changes while the process runs, so it is a table # an operator edits from the dashboard. CODE_ZERO_CONTROL_PLANE_TOKENS= -# Path to the deployment configuration file. Defaults to `code-zero.deployment.yml` in the working -# directory; a deployment that has changed nothing ships no file and gets the defaults. +# Path to the instance configuration file. Defaults to `config.yml` in the working directory (the +# old name `code-zero.deployment.yml` is still read when it is the only one present); a deployment +# that has changed nothing ships no file and gets the defaults. CODE_ZERO_CONFIG= +# Deployment settings (/settings in the dashboard): the limits every run is held to — concurrency, +# the per-command timeout, allowed modes, network ceiling, and optionally one model and one +# container sandbox. Both values are read once at start and no request can change them. +# +# Which product this is: `self-hosted` (the default) or `cloud`, the managed service, where the AI +# provider, sandbox, database and mail are fixed and organizations may only lower their limits. +# Stated, never inferred from the host; an unrecognised value is treated as `cloud` and every run +# is refused until it is corrected. +CODE_ZERO_EDITION=self-hosted +# Where a self-hosted deployment's settings come from, exactly one of: `file` (the default; the +# execution/model/runner sections of config.yml, shown read-only) or `dashboard` (stored in +# Postgres, edited by an admin on the settings page, exportable as YAML). `cloud` requires `file`. +CODE_ZERO_SETTINGS_SOURCE=file + GITHUB_WEBHOOK_SECRET= # Checkout the webhook route binds incoming events to. POST /webhooks/github fails closed # (503, nothing ingested) until both this and GITHUB_WEBHOOK_SECRET are set. @@ -51,7 +66,7 @@ CODE_ZERO_CHECKOUT_PATH= # Which repositories it watches, and the mode each one runs in, are rows in the `repository` table # rather than variables here: an operator turns polling on for a repository from the dashboard and # the next pass picks it up, with no restart. The interval between passes is in -# `code-zero.deployment.yml`. Each row pairs a repository on the provider with the checkout on this +# `config.yml`. Each row pairs a repository on the provider with the checkout on this # host a run may execute against; the path is never derived from the slug, so a run can only ever # target a checkout an operator named. That checkout has to be kept current (a periodic # `git fetch`) — a review reads the diff between the pull request's base and head commits, so a diff --git a/docs/architecture.md b/docs/architecture.md index 34d1473..0692c10 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,6 +119,31 @@ Transport concerns stop at the route handlers: headers, status mapping, and requ + + +## Settings authority boundary + +Deployment settings — how much work a deployment takes on, in which modes, with which model and which sandbox — have exactly one authority at a time, and the environment decides which one before the process serves a request. No request can move it. + +`CODE_ZERO_EDITION` says which product a deployment is: `self-hosted` (the default) or `cloud`, the managed service. It is stated, never inferred: a self-hosted install that happens to run on Cloudflare is still self-hosted. A value the process does not recognise is treated as the locked cloud edition and keeps every run refused until it is corrected, so a typo can never make anything editable. + +A self-hosted deployment reads its settings from one source, chosen with `CODE_ZERO_SETTINGS_SOURCE`: + +- `file` (the default) — the `execution`, `model`, and `runner` sections of the instance file, `config.yml` in the working directory or the path `CODE_ZERO_CONFIG` names (the old name `code-zero.deployment.yml` is still read when it is the only file present). The file is read once at start, the settings page shows it read-only, and `settings.update` refuses every write on the server whatever the page rendered. +- `dashboard` — the `deployment_setting` row in Postgres, edited by an administrator from the settings page through `settings.update` and exportable as the YAML the file would hold through `settings.export`. The export passes through the environment's secret redaction, and the parser refuses any value shaped like a credential, so it cannot carry a secret. Settings sections still present in the file are ignored and reported as a warning, never merged: a value has one home. + +The settings are strict. `@code-zero/config` parses both sources with one parser that refuses an unknown key, an out-of-range value, a flag-shaped image, or unparseable YAML, and refuses the whole document on any issue rather than defaulting field by field. Invalid settings fail closed: `tasks.create`, the webhook route, and the poller start no run, and the page lists every issue with its `$.path`. + +Every run is held to them on top of the repository's own `.code-zero.yml`, stricter winning: `execution.max_concurrent_tasks` is the scheduler's ceiling, `execution.allowed_modes` refuses a run before it is recorded, `execution.command_timeout_minutes` caps how long one repository command may run (and a hosted sandbox lease lasts) — not the whole run, which can execute several commands — `execution.max_network` is a ceiling a repository can only narrow, `model` replaces the repository's model choice, and `runner` forces container isolation with the deployment's engine and image. `observe` is always allowed, since it is the default every unrequested run starts in and the one mode that never writes. + +On the managed cloud the service file is the only source and cannot be switched to the dashboard. The AI provider, the sandbox, the database, and the mail transport are the service's own: customers see only whether each one is configured — no credential, host, or provider name — and the plan's limits. An organization's owners and admins may set stricter limits for their own tasks (concurrency, per-command timeout, allowed modes) in `organization_setting`; the server refuses any value above the plan and takes the minimum again on every run, so a plan lowered later still wins. Membership and role are looked up in Postgres on every call rather than trusted from the session. A customer's run always belongs to one verified organization — an organization API key's own, or the session's selected organization once the member table confirms it — and work the deployment starts itself (webhook deliveries, polling) belongs to none and runs under the plan. + +The `settings.*` procedures are session-only on both editions: an API key or an operator token can neither read the settings nor change them. + +The boundary follows the package rule. `packages/core/config` parses and applies the settings and knows nothing about where they are stored; `packages/database` owns the two tables; `packages/api` authorizes the `settings.*` procedures and turns the settings into the `RunPolicy` a task runs under; `apps/web/server/utils/settings.ts` composes the authority from the environment, the instance file, and the store. + + + ## Issue-to-PR workflow From 7ef478dabe0dcefec8253fade28c72786812ae60 Mon Sep 17 00:00:00 2001 From: RedStar Date: Mon, 19 Oct 2026 12:32:31 +0200 Subject: [PATCH 7/8] fix(config): close two ways the deployment settings could be loosened Under a network ceiling below `full`, a repository that chose container isolation could still name its own container network. `restricted` is the one policy where the container runner reads that name, so `network: host` became `--network host` while the run reported restricted networking. The repository's network name is now dropped whenever the deployment narrows the network, not only when the deployment imposes its own sandbox. The settings parser ignored top-level keys outside its three sections, because the deployment file shares its top level with control_plane and poll. A mistyped `exection:` was therefore accepted and left every limit at its permissive default. Any top-level key that belongs to neither half is now refused; documents saved from the dashboard accept only the settings sections. --- packages/core/config/src/deployment.test.ts | 14 ++++++++ packages/core/config/src/deployment.ts | 3 +- packages/core/config/src/settings.test.ts | 40 +++++++++++++++++++++ packages/core/config/src/settings.ts | 27 +++++++++++--- 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/packages/core/config/src/deployment.test.ts b/packages/core/config/src/deployment.test.ts index 0ec1127..61f49f8 100644 --- a/packages/core/config/src/deployment.test.ts +++ b/packages/core/config/src/deployment.test.ts @@ -183,6 +183,20 @@ execution: }); }); + it('refuses a mistyped section instead of leaving every limit at its default', () => { + const file = parseDeploymentFile(` +poll: + interval_seconds: 30 +exection: + allowed_modes: [observe] +`); + + expect(file.settings).toEqual({ + ok: false, + issues: [{ path: '$.exection', message: 'Unknown section.' }], + }); + }); + it('says when a file states no settings, so a dashboard source has nothing to ignore', () => { expect(parseDeploymentFile('poll:\n interval_seconds: 30\n').declaresSettings).toBe(false); }); diff --git a/packages/core/config/src/deployment.ts b/packages/core/config/src/deployment.ts index fc0b521..30833d9 100644 --- a/packages/core/config/src/deployment.ts +++ b/packages/core/config/src/deployment.ts @@ -6,6 +6,7 @@ import { parse } from 'yaml'; import { declaresDeploymentSettings, + DEPLOYMENT_CONFIG_SECTIONS, parseDeploymentSettings, type DeploymentSettings, type Edition, @@ -222,7 +223,7 @@ export function parseDeploymentFile( } return { ...configFromDocument(parsed), - settings: parseDeploymentSettings(parsed, edition), + settings: parseDeploymentSettings(parsed, edition, DEPLOYMENT_CONFIG_SECTIONS), declaresSettings: declaresDeploymentSettings(parsed), }; } diff --git a/packages/core/config/src/settings.test.ts b/packages/core/config/src/settings.test.ts index e6b09a5..8d51dcd 100644 --- a/packages/core/config/src/settings.test.ts +++ b/packages/core/config/src/settings.test.ts @@ -75,6 +75,20 @@ describe('parseDeploymentSettingsText', () => { }, ); + it('refuses a top-level key that is no section of the deployment file', () => { + expect(parseDeploymentSettingsText('exection:\n allowed_modes: [observe]\n')).toEqual({ + ok: false, + issues: [{ path: '$.exection', message: 'Unknown section.' }], + }); + }); + + it('refuses the configuration sections in a settings document saved from the dashboard', () => { + expect(parseDeploymentSettings({ control_plane: { origins: [] } })).toEqual({ + ok: false, + issues: [{ path: '$.control_plane', message: 'Unknown section.' }], + }); + }); + it('refuses unparseable YAML instead of falling back to the defaults', () => { const parsed = parseDeploymentSettingsText('execution: [unclosed\n'); @@ -382,6 +396,32 @@ describe('applyDeploymentSettings', () => { expect(applied.permissions.network).toBe('restricted'); }); + it('drops the container network a repository named whenever the deployment restricts the network', () => { + const applied = applyDeploymentSettings( + repository({ + permissions: { network: 'full' }, + runner: { + isolation: 'container', + engine: 'docker', + image: 'ghcr.io/acme/runner:1', + network: 'host', + workdir: '/workspace', + maxOutputBytes: 200_000, + }, + }), + { + ...defaultDeploymentSettings, + execution: { ...defaultDeploymentSettings.execution, maxNetwork: 'restricted' }, + }, + ); + + // `restricted` is where the container runner reads a network name, so a repository-chosen + // `host` would otherwise become `--network host` under a restricted ceiling. + expect(applied.permissions.network).toBe('restricted'); + expect(applied.runner).not.toHaveProperty('network'); + expect(applied.runner.isolation).toBe('container'); + }); + it('lets a repository narrow the network below the deployment ceiling', () => { const applied = applyDeploymentSettings( repository({ permissions: { network: 'none' } }), diff --git a/packages/core/config/src/settings.ts b/packages/core/config/src/settings.ts index f7f5c91..bca058d 100644 --- a/packages/core/config/src/settings.ts +++ b/packages/core/config/src/settings.ts @@ -147,6 +147,12 @@ export const noOrganizationLimits: OrganizationLimits = { /** The deployment file's top-level keys that belong to the settings document. */ const SETTINGS_SECTIONS = ['execution', 'model', 'runner'] as const; +/** + * The deployment file's other top-level sections, which `deployment.ts` parses. Named here so the + * file has one list of every section it may hold, and a key that is neither is refused. + */ +export const DEPLOYMENT_CONFIG_SECTIONS: readonly string[] = ['control_plane', 'poll']; + const EXECUTION_KEYS = [ 'max_concurrent_tasks', 'command_timeout_minutes', @@ -211,17 +217,23 @@ export function declaresDeploymentSettings(document: unknown): boolean { * * Unlike the rest of the deployment file, nothing here falls back to a default field by field: a * limit or a sandbox that was meant and mistyped must not quietly become a different one, so the - * caller gets either the complete document or every issue in it. Keys outside the three sections - * are ignored, because they belong to the deployment configuration, which parses them itself. + * caller gets either the complete document or every issue in it. That includes the top level: an + * unknown section is refused, because a mistyped `exection:` would otherwise leave every limit at + * its default. `otherSections` names the top-level keys that belong to someone else — the + * deployment configuration's own sections, when the document is the deployment file. */ export function parseDeploymentSettings( document: unknown, edition: Edition = 'self-hosted', + otherSections: readonly string[] = [], ): Parsed { // An empty file parses to null, and states no setting, exactly like a file that has none. const root = document ?? {}; if (!isRecord(root)) return err('$', 'Expected an object.'); const issues: DeploymentConfigIssue[] = []; + for (const key of Object.keys(root)) + if (!SETTINGS_SECTIONS.some((section) => section === key) && !otherSections.includes(key)) + issues.push({ path: `$.${key}`, message: 'Unknown section.' }); const settings: DeploymentSettings = { execution: executionSection(root.execution, issues), model: modelSection(root.model, issues), @@ -247,7 +259,7 @@ export function parseDeploymentSettingsText( } catch (error) { return err('$', error instanceof Error ? error.message : 'YAML parsing failed.'); } - return parseDeploymentSettings(document, edition); + return parseDeploymentSettings(document, edition, DEPLOYMENT_CONFIG_SECTIONS); } /** @@ -392,8 +404,8 @@ export function effectiveExecution( * the deployment states a model or a sandbox, the deployment's choice replaces the repository's * rather than merging with it. The command timeout is the shorter of the two, and the network the * narrower, whatever executes the run: a repository may ask for less than the deployment allows, - * never more. A repository's own container - * network name is dropped under a deployment sandbox, because `--network host` is a name too. + * never more. A repository's own container network name is dropped under a deployment sandbox and + * under any network ceiling below `full`, because `--network host` is a name too. * * `execution` is the effective limit for this run — the plan lowered by an organization's limits on * the managed cloud — and defaults to the deployment's own. @@ -422,6 +434,11 @@ export function applyDeploymentSettings( engine: settings.runner.engine, image: settings.runner.image, }; + // Under a restricted ceiling the container runner reads the network *name* the repository + // gave (`restricted` is the one policy that uses it), and `host` is a name too. So whenever the + // deployment narrows the network, the repository's own name goes, sandbox or not, and the + // runner falls back to its default restricted network. + else if (execution.maxNetwork !== 'full') applied.runner = withoutContainerNetwork(config.runner); return applied; } From 524754b80edad0a8f8ad4cbb5fd983cac8eb8948 Mon Sep 17 00:00:00 2001 From: RedStar Date: Mon, 19 Oct 2026 12:32:31 +0200 Subject: [PATCH 8/8] fix(web): keep a late settings answer from landing on another organization Saving organization A, switching to B and letting B's read finish first installed A's answer when it arrived: the form showed A's limits under B's name, and the next save went to A. The limits form is now keyed by organization, an answer for an organization the page no longer shows is dropped, and a settings read superseded by a newer one is discarded however late it lands. --- apps/web/app/pages/(dashboard)/settings.vue | 15 ++++- .../settings/composables/useSettings.test.ts | 66 +++++++++++++++++++ .../settings/composables/useSettings.ts | 12 +++- apps/web/test/nuxt/pages/settings.spec.ts | 26 ++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 apps/web/modules/settings/composables/useSettings.test.ts diff --git a/apps/web/app/pages/(dashboard)/settings.vue b/apps/web/app/pages/(dashboard)/settings.vue index d3cc490..dddc27a 100644 --- a/apps/web/app/pages/(dashboard)/settings.vue +++ b/apps/web/app/pages/(dashboard)/settings.vue @@ -97,8 +97,11 @@ + $orpc.settings.export(); const saveOrganization = (request: Parameters[0]) => $orpc.settings.organization.update(request); -/** The organization's save answers with its own view; the rest of the page stays as it was. */ +/** + * The organization's save answers with its own view; the rest of the page stays as it was. + * + * A save can outlive the selection it was made for: start saving one organization, switch to + * another, and the answer arrives after the page already shows the second. Installing it then + * would put the first organization's limits — and its id, which the next save uses — back on + * screen under the second's name, so an answer for an organization no longer shown is dropped. + */ function replaceOrganization(organization: OrganizationSettingsView): void { - if (view.value?.edition === 'cloud') replace({ ...view.value, organization }); + if (view.value?.edition !== 'cloud' || view.value.organization?.id !== organization.id) return; + replace({ ...view.value, organization }); } // Client-side only, like the audit log: the procedure authenticates the browser's session, and diff --git a/apps/web/modules/settings/composables/useSettings.test.ts b/apps/web/modules/settings/composables/useSettings.test.ts new file mode 100644 index 0000000..a34415c --- /dev/null +++ b/apps/web/modules/settings/composables/useSettings.test.ts @@ -0,0 +1,66 @@ +import type { SettingsView } from '@code-zero/api'; +import { describe, expect, it } from 'vitest'; + +import { useSettings } from './useSettings'; + +function cloudView(organizationId: string): SettingsView { + return { + edition: 'cloud', + services: [], + plan: null, + organization: { + id: organizationId, + role: 'owner', + editable: true, + limits: { maxConcurrentTasks: null, commandTimeoutMinutes: null, allowedModes: null }, + invalid: false, + effective: null, + }, + bounds: { + maxConcurrentTasks: { minimum: 1, maximum: 64 }, + commandTimeoutMinutes: { minimum: 1, maximum: 1440 }, + }, + }; +} + +/** A read the test answers by hand, so two reads can finish in the opposite order. */ +function deferredReads() { + const pending: ((view: SettingsView) => void)[] = []; + const read = () => + new Promise((resolve) => { + pending.push(resolve); + }); + return { read, pending }; +} + +describe('useSettings', () => { + it('keeps the latest read when an earlier one answers last', async () => { + const { read, pending } = deferredReads(); + const settings = useSettings(read); + + const first = settings.refresh(); + const second = settings.refresh(); + pending[1]?.(cloudView('org_b')); + await second; + pending[0]?.(cloudView('org_a')); + await first; + + expect(settings.view.value?.edition === 'cloud' && settings.view.value.organization?.id).toBe( + 'org_b', + ); + expect(settings.pending.value).toBe(false); + }); + + it('stays pending until the latest read answers', async () => { + const { read, pending } = deferredReads(); + const settings = useSettings(read); + + const first = settings.refresh(); + void settings.refresh(); + pending[0]?.(cloudView('org_a')); + await first; + + expect(settings.pending.value).toBe(true); + expect(settings.view.value).toBeNull(); + }); +}); diff --git a/apps/web/modules/settings/composables/useSettings.ts b/apps/web/modules/settings/composables/useSettings.ts index 105dcf9..992bdc3 100644 --- a/apps/web/modules/settings/composables/useSettings.ts +++ b/apps/web/modules/settings/composables/useSettings.ts @@ -19,17 +19,25 @@ export function useSettings(read: SettingsReader) { const view = shallowRef(null); const pending = shallowRef(false); const error = shallowRef(null); + // Reads can overlap — the organization switcher asks again while an earlier read is in flight — + // and only the most recent request describes what the page should show. Each read takes a + // number, and an answer whose number is no longer the latest is dropped, however late it lands. + let latest = 0; async function refresh(): Promise { + const request = ++latest; pending.value = true; error.value = null; try { - view.value = await read(); + const next = await read(); + if (request !== latest) return; + view.value = next; } catch (caught) { + if (request !== latest) return; view.value = null; error.value = classifySettingsError(caught); } finally { - pending.value = false; + if (request === latest) pending.value = false; } } diff --git a/apps/web/test/nuxt/pages/settings.spec.ts b/apps/web/test/nuxt/pages/settings.spec.ts index 24062bd..528807a 100644 --- a/apps/web/test/nuxt/pages/settings.spec.ts +++ b/apps/web/test/nuxt/pages/settings.spec.ts @@ -172,6 +172,32 @@ describe('settings page', () => { expect(wrapper.text()).toContain('Select an organization to see its limits.'); }); + it('drops a save answer for an organization the page no longer shows', async () => { + view.value = cloudView({ ...organization(true), id: 'org_a' }); + const wrapper = await mountSuspended(SettingsPage); + const form = wrapper.findComponent({ name: 'SettingsOrganizationLimits' }); + + // The reader switched to another organization while the first one's save was in flight. + view.value = cloudView({ ...organization(true), id: 'org_b' }); + await nextTick(); + form.vm.$emit('saved', { ...organization(true), id: 'org_a' }); + await nextTick(); + + expect(view.value?.edition === 'cloud' && view.value.organization?.id).toBe('org_b'); + }); + + it('ignores a save answer that names another organization, even from the shown form', async () => { + view.value = cloudView({ ...organization(true), id: 'org_b' }); + const wrapper = await mountSuspended(SettingsPage); + + wrapper + .findComponent({ name: 'SettingsOrganizationLimits' }) + .vm.$emit('saved', { ...organization(true), id: 'org_a' }); + await nextTick(); + + expect(view.value?.edition === 'cloud' && view.value.organization?.id).toBe('org_b'); + }); + it('reads the settings again when the sidebar switches organization', async () => { view.value = cloudView(null); await mountSuspended(SettingsPage);