diff --git a/.version b/.version index 80ea31f9..06457273 100644 --- a/.version +++ b/.version @@ -1,6 +1,6 @@ { "major": 1, - "minor": 8, - "patch": 22, + "minor": 9, + "patch": 0, "prerelease": "" } diff --git a/api-schema/tmi-openapi.json b/api-schema/tmi-openapi.json index 109e71ab..3327663a 100644 --- a/api-schema/tmi-openapi.json +++ b/api-schema/tmi-openapi.json @@ -3,7 +3,7 @@ "info": { "title": "TMI (Threat Modeling Improved) API", "description": "A RESTful API for collaborative threat modeling with full X6 graph library compatibility. This API provides schemas that align with AntV X6 cell object models for seamless integration with modern diagramming libraries. Supports OAuth 2.0 authentication with client callback integration for seamless single-page application authentication flows.\n\n## API Design v1.1.0\n\n### Authorization Model\nTMI uses hierarchical authorization: access control is defined at the ThreatModel level via the authorization field (readers, writers, owners). All child resources (Assets, Diagrams, Documents, Notes, Repositories, Threats) inherit permissions from their parent ThreatModel. This simplifies permission management and ensures consistent access control.\n\n### Bulk Operations\nNotes and Diagrams do not support bulk operations due to their unique creation workflows and lack of valid bulk use cases. All other resources (Threats, Assets, Documents, Repositories) support full bulk operations: POST (create), PUT (upsert), PATCH (partial update), DELETE (batch delete).\n\nAll resources support bulk metadata operations regardless of resource-level bulk support.\n\n### List Response Strategy\n- ThreatModels return summary information (TMListItem) because they contain many child objects that can be large.\n- Diagrams return summary information (DiagramListItem) because diagram data (cells, images) can be large.\n- Notes return summary information (NoteListItem) because the content field can be large.\n- Threats, Assets, Documents, Repositories return full schemas as they are relatively small and static.\n\n### PATCH Support\nAll resources support PATCH for partial updates using JSON Patch (RFC 6902). This is particularly useful for:\n- Assets: Array field updates (affected_assets, trust_boundaries) ensuring no duplicates\n- Notes: Updating name/description without changing content field\n- All resources: Efficient updates without full object replacement\n", - "version": "1.8.22", + "version": "1.9.0", "contact": { "name": "TMI Development Team", "url": "https://github.com/ericfitz/tmi", diff --git a/api/api.go b/api/api.go index 88d2d1fa..28758793 100644 --- a/api/api.go +++ b/api/api.go @@ -25952,1089 +25952,1089 @@ var swaggerSpec = []string{ "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/api/models/system_setting.go b/api/models/system_setting.go index 55b15a05..d52dc2a2 100644 --- a/api/models/system_setting.go +++ b/api/models/system_setting.go @@ -3,6 +3,8 @@ package models import ( "time" + + "github.com/ericfitz/tmi/internal/config" ) // SystemSetting represents a system-wide configuration setting stored in the database. @@ -102,64 +104,44 @@ func (s *SystemSetting) IsExplicit() bool { // DefaultSystemSettings returns the default system settings that should be seeded // when the database is initialized. These provide sensible defaults that can be // overridden by administrators. -// SEM@8f7b5125fd7a1b5bb10210ba480278708de918b0: build the seed list of default system settings for database initialization (pure) +// +// This is a projection of the internal/config registry +// (config.SeedableOperationalDefs), not a hand-kept parallel list — a +// parallel list is what left rate_limit.requests_per_minute and +// rate_limit.requests_per_hour seeded here with no classification entry +// anywhere else, so GET/DELETE /admin/settings/{key} 404'd on keys the LIST +// endpoint showed (#809). +// SEM@62e82fc4e96a1c18f4e1ac1d698de4fefb974b42: build the seed list of default system settings for database initialization (pure) func DefaultSystemSettings() []SystemSetting { desc := func(s string) NullableDBText { return NullableDBText{String: s, Valid: true} } - return []SystemSetting{ - { - SettingKey: "rate_limit.requests_per_minute", - Value: "100", - SettingType: SystemSettingTypeInt, - Description: desc("Maximum API requests per minute per user"), - }, - { - SettingKey: "rate_limit.requests_per_hour", - Value: "1000", - SettingType: SystemSettingTypeInt, - Description: desc("Maximum API requests per hour per user"), - }, - { - SettingKey: "session.timeout_minutes", - Value: "60", - SettingType: SystemSettingTypeInt, - Description: desc("JWT token expiration in minutes"), - }, - { - SettingKey: "websocket.max_participants", - Value: "10", - SettingType: SystemSettingTypeInt, - Description: desc("Maximum participants per collaboration session"), - }, - { - SettingKey: "features.saml_enabled", - Value: "false", - SettingType: SystemSettingTypeBool, - Description: desc("Enable SAML authentication"), - }, - { - SettingKey: "features.webhooks_enabled", - Value: "true", - SettingType: SystemSettingTypeBool, - Description: desc("Enable webhook subscriptions"), - }, - { - SettingKey: "features.websocket_enabled", - Value: "true", - SettingType: SystemSettingTypeBool, - Description: desc("Enable WebSocket collaboration"), - }, - { - SettingKey: "ui.default_theme", - Value: "auto", - SettingType: SystemSettingTypeString, - Description: desc("Default UI theme (auto, light, dark)"), - }, - { - SettingKey: "upload.max_file_size_mb", - Value: "10", - SettingType: SystemSettingTypeInt, - Description: desc("Maximum file upload size in megabytes"), - }, + defs := config.SeedableOperationalDefs() + out := make([]SystemSetting, 0, len(defs)) + for _, d := range defs { + out = append(out, SystemSetting{ + SettingKey: DBVarchar(d.Key), + Value: DBText(d.Default), + SettingType: DBVarchar(settingTypeFor(d.Type)), + Description: desc(d.Description), + }) + } + return out +} + +// settingTypeFor maps a config registry type name to the stored +// system_settings setting_type value. +// SEM@62e82fc4e96a1c18f4e1ac1d698de4fefb974b42: convert a config registry type name to a stored setting_type value (pure) +func settingTypeFor(t string) string { + switch t { + case "bool": + return SystemSettingTypeBool + case "int": + return SystemSettingTypeInt + case "float": + return SystemSettingTypeFloat + case "json": + return SystemSettingTypeJSON + default: + return SystemSettingTypeString } } diff --git a/api/models/system_setting_test.go b/api/models/system_setting_test.go index 13aaf804..c3838227 100644 --- a/api/models/system_setting_test.go +++ b/api/models/system_setting_test.go @@ -3,6 +3,10 @@ package models import ( "encoding/json" "testing" + + "github.com/ericfitz/tmi/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestSystemSetting_IsExplicit pins the #794 precedence rule and, critically, @@ -92,3 +96,38 @@ func TestSystemSetting_OriginJSONRoundTrip(t *testing.T) { t.Error("round-tripped seeded row reports IsExplicit() == true; Origin was lost in JSON marshaling") } } + +// TestDefaultSystemSettings_MatchesRegistryProjection pins that +// DefaultSystemSettings is a projection of config.SeedableOperationalDefs, +// not a hand-kept parallel list — a parallel list is what left rate_limit.* +// seeded here with no classification entry anywhere else (#809). +func TestDefaultSystemSettings_MatchesRegistryProjection(t *testing.T) { + defs := config.SeedableOperationalDefs() + seeds := DefaultSystemSettings() + + require.Equal(t, len(defs), len(seeds), + "DefaultSystemSettings must be a projection of the registry, not a parallel list") + + byKey := map[string]SystemSetting{} + for _, s := range seeds { + byKey[string(s.SettingKey)] = s + } + for _, d := range defs { + s, ok := byKey[d.Key] + require.True(t, ok, "registry declares %s but DefaultSystemSettings does not seed it", d.Key) + assert.Equal(t, d.Default, string(s.Value), "seed value for %s must be the registry Default", d.Key) + assert.Equal(t, d.Description, s.Description.String, "seed description for %s", d.Key) + } +} + +// TestDefaultSystemSettings_SeedsPreviouslyUnclassifiedRateLimitKeys pins +// the #809 fix: both rate_limit.* keys are still seeded after the switch to +// a registry projection. +func TestDefaultSystemSettings_SeedsPreviouslyUnclassifiedRateLimitKeys(t *testing.T) { + keys := map[string]bool{} + for _, s := range DefaultSystemSettings() { + keys[string(s.SettingKey)] = true + } + assert.True(t, keys["rate_limit.requests_per_minute"]) + assert.True(t, keys["rate_limit.requests_per_hour"]) +} diff --git a/api/version.go b/api/version.go index 121ebece..ad5f51d7 100644 --- a/api/version.go +++ b/api/version.go @@ -50,9 +50,9 @@ var ( // Major version number VersionMajor = "1" // Minor version number - VersionMinor = "8" + VersionMinor = "9" // Patch version number - VersionPatch = "22" + VersionPatch = "0" // VersionPreRelease is the pre-release label (e.g., "rc.0", "beta.1"), empty for stable releases VersionPreRelease = "" // GitCommit is the git commit hash from build diff --git a/config-example.yml b/config-example.yml index 9b7fa14d..e5ee637c 100644 --- a/config-example.yml +++ b/config-example.yml @@ -1,5 +1,5 @@ # TMI Example Configuration (bootstrap keys only) -# GENERATED by `make generate-config-example` on 2026-06-11T20:57:37Z — do not edit by hand. +# GENERATED by `make generate-config-example` on 2026-08-22T23:34:47Z — do not edit by hand. # Operational and shared configuration lives in the database settings service. # Secret values are shown as vault:// reference placeholders. @@ -48,4 +48,5 @@ server: read_timeout: 5s tls_enabled: false tls_subject_name: localhost + trusted_proxies: [] write_timeout: 10s diff --git a/config-reference.md b/config-reference.md index 8bbfe78c..cca21d5f 100644 --- a/config-reference.md +++ b/config-reference.md @@ -1,6 +1,6 @@ # Configuration Reference - + Every TMI configuration key, grouped by category. See [[Configuration-Model]] for what the categories and columns mean. @@ -63,81 +63,82 @@ File/env only, read once at startup. Cannot come from the database. | `server.read_timeout` | `TMI_SERVER_READ_TIMEOUT` | string | `5s` | no | no | config/env only | HTTP read timeout | | `server.tls_enabled` | `TMI_SERVER_TLS_ENABLED` | bool | `false` | no | no | config/env only | TLS enabled | | `server.tls_subject_name` | `TMI_SERVER_TLS_SUBJECT_NAME` | string | `localhost` | no | no | config/env only | TLS certificate subject name | +| `server.trusted_proxies` | `TMI_TRUSTED_PROXIES` | json | `null` | no | no | config/env only | Comma-separated CIDRs/IPs for X-Forwarded-For trusted-proxy validation | | `server.write_timeout` | `TMI_SERVER_WRITE_TIMEOUT` | string | `10s` | no | no | config/env only | HTTP write timeout | ## Operational settings -DB-backed, seeded from defaults on first run, editable at runtime via `/admin/settings`. +DB-backed, seeded from defaults on first run, editable at runtime via `/admin/settings`. A setting with an env var is Transitional: still config/env-eligible during migration (see [[Configuration-Model]]) — most operational settings have no env var at all, since they exist only in the database. -| Key | Type | Default | Mutability | Visibility | Secret | Precedence | Description | -|-----|------|---------|------------|------------|--------|------------|-------------| -| `auth.auto_promote_first_user` | bool | `false` | hot | admin-only | no | db unless config explicit | Auto-promote first user to admin | -| `auth.cookie.domain` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Cookie domain | -| `auth.cookie.enabled` | bool | `true` | hot | admin-only | no | db unless config explicit | HttpOnly cookie-based auth enabled | -| `auth.cookie.secure` | bool | `false` | hot | admin-only | no | db unless config explicit | Require HTTPS for cookies | -| `auth.everyone_is_a_reviewer` | bool | `false` | hot | admin-only | no | db unless config explicit | Auto-add all users to Security Reviewers group | -| `auth.jwt.expiration_seconds` | int | `3600` | hot | admin-only | no | db unless config explicit | JWT token expiration in seconds | -| `auth.jwt.refresh_token_days` | int | `7` | hot | admin-only | no | db unless config explicit | Refresh token TTL in days | -| `auth.jwt.session_lifetime_days` | int | `7` | hot | admin-only | no | db unless config explicit | Absolute session lifetime in days | -| `auth.oauth_callback_url` | string | `http://localhost:8080/oauth2/callback` | hot | admin-only | no | db unless config explicit | OAuth callback URL | -| `auth.step_up_window_seconds` | int | `300` | hot | admin-only | no | db unless config explicit | Step-up auth_time freshness window in seconds for /admin/* writes (#355); minimum 60 | -| `content_extractors.compressed_size_bytes` | int | `20971520` | hot | admin-only | no | db unless config explicit | Max compressed upload size in bytes | -| `content_extractors.decompressed_size_bytes` | int | `52428800` | hot | admin-only | no | db unless config explicit | Max decompressed content size in bytes | -| `content_extractors.markdown_size_bytes` | int | `131072` | hot | admin-only | no | db unless config explicit | Max markdown output size in bytes | -| `content_extractors.part_size_bytes` | int | `20971520` | hot | admin-only | no | db unless config explicit | Max size of a single archive part in bytes | -| `content_extractors.per_user_concurrency_default` | int | `2` | hot | admin-only | no | db unless config explicit | Default max concurrent extractions per user | -| `content_extractors.pptx_slides` | int | `100` | hot | admin-only | no | db unless config explicit | Max number of PowerPoint slides to extract | -| `content_extractors.wall_clock_budget` | string | `30s` | hot | admin-only | no | db unless config explicit | Max wall-clock time for a single extraction | -| `content_extractors.xlsx_cells` | int | `1000` | hot | admin-only | no | db unless config explicit | Max number of Excel cells to extract | -| `content_sources.confluence.enabled` | bool | `false` | hot | admin-only | no | db unless config explicit | Confluence content source enabled | -| `content_sources.google_drive.enabled` | bool | `false` | hot | admin-only | no | db unless config explicit | Google Drive content source enabled | -| `content_sources.google_workspace.enabled` | bool | `false` | hot | admin-only | no | db unless config explicit | Google Workspace content source enabled | -| `content_sources.microsoft.enabled` | bool | `false` | hot | admin-only | no | db unless config explicit | Microsoft content source enabled | -| `extraction.async_enabled` | bool | `false` | hot | admin-only | no | db unless config explicit | Route document extraction through the async worker pipeline instead of inline (default false; requires NATS) | -| `features.saml_enabled` | bool | `false` | hot | public | no | db unless config explicit | Enable SAML authentication | -| `observability.enabled` | bool | `false` | hot | admin-only | no | db unless config explicit | OpenTelemetry tracing enabled | -| `observability.prometheus_port` | int | `0` | hot | admin-only | no | db unless config explicit | Prometheus metrics port (0 = disabled) | -| `observability.sampling_rate` | float | `1` | hot | admin-only | no | db unless config explicit | OpenTelemetry trace sampling rate (0.0–1.0) | -| `server.disable_rate_limiting` | bool | `false` | hot | admin-only | no | db unless config explicit | Disable all rate limiting (dev/test only) | -| `server.ratelimit_public_rpm` | int | `0` | hot | admin-only | no | db unless config explicit | Requests per minute per IP for public endpoints | -| `server.require_if_match` | bool | `false` | hot | admin-only | no | db unless config explicit | Return 428 when If-Match header is missing on PUT/PATCH | -| `session.timeout_minutes` | int | `60` | hot | admin-only | no | db unless config explicit | JWT token expiration in minutes | -| `timmy.chunk_overlap` | int | `50` | hot | admin-only | no | db unless config explicit | Embedding chunk overlap | -| `timmy.chunk_size` | int | `512` | hot | admin-only | no | db unless config explicit | Embedding chunk size | -| `timmy.code_embedding_api_key` | string | _(secret)_ | hot | admin-only | yes | db unless config explicit | Code embedding API key | -| `timmy.code_embedding_base_url` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Code embedding API base URL | -| `timmy.code_embedding_model` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Code embedding model | -| `timmy.code_embedding_provider` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Code embedding provider | -| `timmy.code_retrieval_top_k` | int | `10` | hot | admin-only | no | db unless config explicit | Code retrieval top-k results | -| `timmy.dump_extracted_text_to_note` | bool | `false` | hot | admin-only | no | db unless config explicit | Dump extracted text to note (dev/test only) | -| `timmy.embedding_cleanup_interval_minutes` | int | `60` | hot | admin-only | no | db unless config explicit | Embedding cleanup interval in minutes | -| `timmy.embedding_dimension` | int | `0` | hot | admin-only | no | db unless config explicit | Text embedding vector dimension — shared invariant | -| `timmy.embedding_idle_days_active` | int | `30` | hot | admin-only | no | db unless config explicit | Days before idle active-TM embeddings are cleaned up | -| `timmy.embedding_idle_days_closed` | int | `7` | hot | admin-only | no | db unless config explicit | Days before idle closed-TM embeddings are cleaned up | -| `timmy.enabled` | bool | `false` | hot | admin-only | no | db unless config explicit | Timmy AI assistant enabled | -| `timmy.inactivity_timeout_seconds` | int | `3600` | hot | admin-only | no | db unless config explicit | Session inactivity timeout in seconds | -| `timmy.llm_api_key` | string | _(secret)_ | hot | admin-only | yes | db unless config explicit | LLM API key | -| `timmy.llm_base_url` | string | _(none)_ | hot | admin-only | no | db unless config explicit | LLM API base URL | -| `timmy.llm_max_tokens` | int | `4096` | hot | admin-only | no | db unless config explicit | Max tokens per chat completion (required by Anthropic; optional for OpenAI) | -| `timmy.llm_model` | string | _(none)_ | hot | admin-only | no | db unless config explicit | LLM model | -| `timmy.llm_provider` | string | _(none)_ | hot | admin-only | no | db unless config explicit | LLM provider | -| `timmy.llm_timeout_seconds` | int | `120` | hot | admin-only | no | db unless config explicit | LLM request timeout in seconds | -| `timmy.max_concurrent_llm_requests` | int | `10` | hot | admin-only | no | db unless config explicit | Max concurrent LLM requests | -| `timmy.max_conversation_history` | int | `50` | hot | admin-only | no | db unless config explicit | Max conversation history entries | -| `timmy.max_memory_mb` | int | `256` | hot | admin-only | no | db unless config explicit | Max memory in MB | -| `timmy.max_messages_per_user_per_hour` | int | `60` | hot | admin-only | no | db unless config explicit | Max messages per user per hour | -| `timmy.max_sessions_per_threat_model` | int | `50` | hot | admin-only | no | db unless config explicit | Max Timmy sessions per threat model | -| `timmy.operator_system_prompt` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Operator system prompt override | -| `timmy.query_decomposition_enabled` | bool | `false` | hot | admin-only | no | db unless config explicit | Query decomposition enabled | -| `timmy.rerank_api_key` | string | _(secret)_ | hot | admin-only | yes | db unless config explicit | Reranker API key | -| `timmy.rerank_base_url` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Reranker API base URL | -| `timmy.rerank_model` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Reranker model | -| `timmy.rerank_provider` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Reranker provider | -| `timmy.rerank_top_k` | int | `10` | hot | admin-only | no | db unless config explicit | Reranker top-k results | -| `timmy.text_embedding_api_key` | string | _(secret)_ | hot | admin-only | yes | db unless config explicit | Text embedding API key | -| `timmy.text_embedding_base_url` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Text embedding API base URL — shared invariant | -| `timmy.text_embedding_model` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Text embedding model — shared invariant between ingest and query | -| `timmy.text_embedding_provider` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Text embedding provider | -| `timmy.text_retrieval_top_k` | int | `10` | hot | admin-only | no | db unless config explicit | Text retrieval top-k results | -| `webhooks.allow_http_targets` | bool | `false` | hot | admin-only | no | db unless config explicit | Allow non-HTTPS webhook target URLs (intra-cluster use only) | -| `websocket.inactivity_timeout_seconds` | int | `300` | hot | admin-only | no | db unless config explicit | WebSocket inactivity timeout in seconds | +| Key | Env var | Type | Default | Mutability | Visibility | Secret | Precedence | Description | +|-----|---------|------|---------|------------|------------|--------|------------|-------------| +| `auth.auto_promote_first_user` | `TMI_AUTH_AUTO_PROMOTE_FIRST_USER` | bool | `false` | hot | admin-only | no | db unless config explicit | Auto-promote first user to admin | +| `auth.cookie.domain` | `TMI_COOKIE_DOMAIN` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Cookie domain | +| `auth.cookie.enabled` | `TMI_COOKIE_ENABLED` | bool | `true` | hot | admin-only | no | db unless config explicit | HttpOnly cookie-based auth enabled | +| `auth.cookie.secure` | `TMI_COOKIE_SECURE` | bool | `false` | hot | admin-only | no | db unless config explicit | Require HTTPS for cookies | +| `auth.everyone_is_a_reviewer` | `TMI_AUTH_EVERYONE_IS_A_REVIEWER` | bool | `false` | hot | admin-only | no | db unless config explicit | Auto-add all users to Security Reviewers group | +| `auth.jwt.expiration_seconds` | `TMI_JWT_EXPIRATION_SECONDS` | int | `3600` | hot | admin-only | no | db unless config explicit | JWT token expiration in seconds | +| `auth.jwt.refresh_token_days` | `TMI_REFRESH_TOKEN_DAYS` | int | `7` | hot | admin-only | no | db unless config explicit | Refresh token TTL in days | +| `auth.jwt.session_lifetime_days` | `TMI_SESSION_LIFETIME_DAYS` | int | `7` | hot | admin-only | no | db unless config explicit | Absolute session lifetime in days | +| `auth.oauth_callback_url` | `TMI_OAUTH_CALLBACK_URL` | string | `http://localhost:8080/oauth2/callback` | hot | admin-only | no | db unless config explicit | OAuth callback URL | +| `auth.step_up_window_seconds` | `TMI_AUTH_STEP_UP_WINDOW_SECONDS` | int | `300` | hot | admin-only | no | db unless config explicit | Step-up auth_time freshness window in seconds for /admin/* writes (#355); minimum 60 | +| `content_extractors.compressed_size_bytes` | `TMI_CONTENT_EXTRACTORS_COMPRESSED_SIZE_BYTES` | int | `20971520` | hot | admin-only | no | db unless config explicit | Max compressed upload size in bytes | +| `content_extractors.decompressed_size_bytes` | `TMI_CONTENT_EXTRACTORS_DECOMPRESSED_SIZE_BYTES` | int | `52428800` | hot | admin-only | no | db unless config explicit | Max decompressed content size in bytes | +| `content_extractors.markdown_size_bytes` | `TMI_CONTENT_EXTRACTORS_MARKDOWN_SIZE_BYTES` | int | `131072` | hot | admin-only | no | db unless config explicit | Max markdown output size in bytes | +| `content_extractors.part_size_bytes` | `TMI_CONTENT_EXTRACTORS_PART_SIZE_BYTES` | int | `20971520` | hot | admin-only | no | db unless config explicit | Max size of a single archive part in bytes | +| `content_extractors.per_user_concurrency_default` | `TMI_CONTENT_EXTRACTORS_PER_USER_CONCURRENCY_DEFAULT` | int | `2` | hot | admin-only | no | db unless config explicit | Default max concurrent extractions per user | +| `content_extractors.pptx_slides` | `TMI_CONTENT_EXTRACTORS_PPTX_SLIDES` | int | `100` | hot | admin-only | no | db unless config explicit | Max number of PowerPoint slides to extract | +| `content_extractors.wall_clock_budget` | `TMI_CONTENT_EXTRACTORS_WALL_CLOCK_BUDGET` | string | `30s` | hot | admin-only | no | db unless config explicit | Max wall-clock time for a single extraction | +| `content_extractors.xlsx_cells` | `TMI_CONTENT_EXTRACTORS_XLSX_CELLS` | int | `1000` | hot | admin-only | no | db unless config explicit | Max number of Excel cells to extract | +| `content_sources.confluence.enabled` | `TMI_CONTENT_SOURCE_CONFLUENCE_ENABLED` | bool | `false` | hot | admin-only | no | db unless config explicit | Confluence content source enabled | +| `content_sources.google_drive.enabled` | `TMI_CONTENT_SOURCE_GOOGLE_DRIVE_ENABLED` | bool | `false` | hot | admin-only | no | db unless config explicit | Google Drive content source enabled | +| `content_sources.google_workspace.enabled` | `TMI_CONTENT_SOURCE_GOOGLE_WORKSPACE_ENABLED` | bool | `false` | hot | admin-only | no | db unless config explicit | Google Workspace content source enabled | +| `content_sources.microsoft.enabled` | `TMI_CONTENT_SOURCE_MICROSOFT_ENABLED` | bool | `false` | hot | admin-only | no | db unless config explicit | Microsoft content source enabled | +| `extraction.async_enabled` | `TMI_EXTRACTION_ASYNC_ENABLED` | bool | `false` | hot | admin-only | no | db unless config explicit | Route document extraction through the async worker pipeline instead of inline (default false; requires NATS) | +| `features.saml_enabled` | `TMI_SAML_ENABLED` | bool | `false` | hot | public | no | db unless config explicit | Enable SAML authentication | +| `observability.enabled` | `TMI_OTEL_ENABLED` | bool | `false` | hot | admin-only | no | db unless config explicit | OpenTelemetry tracing enabled | +| `observability.prometheus_port` | `TMI_OTEL_PROMETHEUS_PORT` | int | `0` | hot | admin-only | no | db unless config explicit | Prometheus metrics port (0 = disabled) | +| `observability.sampling_rate` | `TMI_OTEL_SAMPLING_RATE` | float | `1` | hot | admin-only | no | db unless config explicit | OpenTelemetry trace sampling rate (0.0–1.0) | +| `server.disable_rate_limiting` | `TMI_DISABLE_RATE_LIMITING` | bool | `false` | hot | admin-only | no | db unless config explicit | Disable all rate limiting (dev/test only) | +| `server.ratelimit_public_rpm` | `TMI_RATELIMIT_PUBLIC_RPM` | int | `0` | hot | admin-only | no | db unless config explicit | Requests per minute per IP for public endpoints | +| `server.require_if_match` | `TMI_REQUIRE_IF_MATCH` | bool | `false` | hot | admin-only | no | db unless config explicit | Return 428 when If-Match header is missing on PUT/PATCH | +| `session.timeout_minutes` | `TMI_JWT_EXPIRATION_SECONDS` | int | `60` | hot | admin-only | no | db unless config explicit | JWT token expiration in minutes | +| `timmy.chunk_overlap` | `TMI_TIMMY_CHUNK_OVERLAP` | int | `50` | hot | admin-only | no | db unless config explicit | Embedding chunk overlap | +| `timmy.chunk_size` | `TMI_TIMMY_CHUNK_SIZE` | int | `512` | hot | admin-only | no | db unless config explicit | Embedding chunk size | +| `timmy.code_embedding_api_key` | `TMI_TIMMY_CODE_EMBEDDING_API_KEY` | string | _(secret)_ | hot | admin-only | yes | db unless config explicit | Code embedding API key | +| `timmy.code_embedding_base_url` | `TMI_TIMMY_CODE_EMBEDDING_BASE_URL` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Code embedding API base URL | +| `timmy.code_embedding_model` | `TMI_TIMMY_CODE_EMBEDDING_MODEL` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Code embedding model | +| `timmy.code_embedding_provider` | `TMI_TIMMY_CODE_EMBEDDING_PROVIDER` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Code embedding provider | +| `timmy.code_retrieval_top_k` | `TMI_TIMMY_CODE_RETRIEVAL_TOP_K` | int | `10` | hot | admin-only | no | db unless config explicit | Code retrieval top-k results | +| `timmy.dump_extracted_text_to_note` | `TMI_TIMMY_DUMP_EXTRACTED_TEXT_TO_NOTE` | bool | `false` | hot | admin-only | no | db unless config explicit | Dump extracted text to note (dev/test only) | +| `timmy.embedding_cleanup_interval_minutes` | `TMI_TIMMY_EMBEDDING_CLEANUP_INTERVAL_MINUTES` | int | `60` | hot | admin-only | no | db unless config explicit | Embedding cleanup interval in minutes | +| `timmy.embedding_dimension` | `TMI_TIMMY_EMBEDDING_DIMENSION` | int | `0` | hot | admin-only | no | db unless config explicit | Text embedding vector dimension — shared invariant | +| `timmy.embedding_idle_days_active` | `TMI_TIMMY_EMBEDDING_IDLE_DAYS_ACTIVE` | int | `30` | hot | admin-only | no | db unless config explicit | Days before idle active-TM embeddings are cleaned up | +| `timmy.embedding_idle_days_closed` | `TMI_TIMMY_EMBEDDING_IDLE_DAYS_CLOSED` | int | `7` | hot | admin-only | no | db unless config explicit | Days before idle closed-TM embeddings are cleaned up | +| `timmy.enabled` | `TMI_TIMMY_ENABLED` | bool | `false` | hot | admin-only | no | db unless config explicit | Timmy AI assistant enabled | +| `timmy.inactivity_timeout_seconds` | `TMI_TIMMY_INACTIVITY_TIMEOUT_SECONDS` | int | `3600` | hot | admin-only | no | db unless config explicit | Session inactivity timeout in seconds | +| `timmy.llm_api_key` | `TMI_TIMMY_LLM_API_KEY` | string | _(secret)_ | hot | admin-only | yes | db unless config explicit | LLM API key | +| `timmy.llm_base_url` | `TMI_TIMMY_LLM_BASE_URL` | string | _(none)_ | hot | admin-only | no | db unless config explicit | LLM API base URL | +| `timmy.llm_max_tokens` | `TMI_TIMMY_LLM_MAX_TOKENS` | int | `4096` | hot | admin-only | no | db unless config explicit | Max tokens per chat completion (required by Anthropic; optional for OpenAI) | +| `timmy.llm_model` | `TMI_TIMMY_LLM_MODEL` | string | _(none)_ | hot | admin-only | no | db unless config explicit | LLM model | +| `timmy.llm_provider` | `TMI_TIMMY_LLM_PROVIDER` | string | _(none)_ | hot | admin-only | no | db unless config explicit | LLM provider | +| `timmy.llm_timeout_seconds` | `TMI_TIMMY_LLM_TIMEOUT_SECONDS` | int | `120` | hot | admin-only | no | db unless config explicit | LLM request timeout in seconds | +| `timmy.max_concurrent_llm_requests` | `TMI_TIMMY_MAX_CONCURRENT_LLM_REQUESTS` | int | `10` | hot | admin-only | no | db unless config explicit | Max concurrent LLM requests | +| `timmy.max_conversation_history` | `TMI_TIMMY_MAX_CONVERSATION_HISTORY` | int | `50` | hot | admin-only | no | db unless config explicit | Max conversation history entries | +| `timmy.max_memory_mb` | `TMI_TIMMY_MAX_MEMORY_MB` | int | `256` | hot | admin-only | no | db unless config explicit | Max memory in MB | +| `timmy.max_messages_per_user_per_hour` | `TMI_TIMMY_MAX_MESSAGES_PER_USER_PER_HOUR` | int | `60` | hot | admin-only | no | db unless config explicit | Max messages per user per hour | +| `timmy.max_sessions_per_threat_model` | `TMI_TIMMY_MAX_SESSIONS_PER_THREAT_MODEL` | int | `50` | hot | admin-only | no | db unless config explicit | Max Timmy sessions per threat model | +| `timmy.operator_system_prompt` | `TMI_TIMMY_OPERATOR_SYSTEM_PROMPT` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Operator system prompt override | +| `timmy.query_decomposition_enabled` | `TMI_TIMMY_QUERY_DECOMPOSITION_ENABLED` | bool | `false` | hot | admin-only | no | db unless config explicit | Query decomposition enabled | +| `timmy.rerank_api_key` | `TMI_TIMMY_RERANK_API_KEY` | string | _(secret)_ | hot | admin-only | yes | db unless config explicit | Reranker API key | +| `timmy.rerank_base_url` | `TMI_TIMMY_RERANK_BASE_URL` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Reranker API base URL | +| `timmy.rerank_model` | `TMI_TIMMY_RERANK_MODEL` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Reranker model | +| `timmy.rerank_provider` | `TMI_TIMMY_RERANK_PROVIDER` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Reranker provider | +| `timmy.rerank_top_k` | `TMI_TIMMY_RERANK_TOP_K` | int | `10` | hot | admin-only | no | db unless config explicit | Reranker top-k results | +| `timmy.text_embedding_api_key` | `TMI_TIMMY_TEXT_EMBEDDING_API_KEY` | string | _(secret)_ | hot | admin-only | yes | db unless config explicit | Text embedding API key | +| `timmy.text_embedding_base_url` | `TMI_TIMMY_TEXT_EMBEDDING_BASE_URL` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Text embedding API base URL — shared invariant | +| `timmy.text_embedding_model` | `TMI_TIMMY_TEXT_EMBEDDING_MODEL` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Text embedding model — shared invariant between ingest and query | +| `timmy.text_embedding_provider` | `TMI_TIMMY_TEXT_EMBEDDING_PROVIDER` | string | _(none)_ | hot | admin-only | no | db unless config explicit | Text embedding provider | +| `timmy.text_retrieval_top_k` | `TMI_TIMMY_TEXT_RETRIEVAL_TOP_K` | int | `10` | hot | admin-only | no | db unless config explicit | Text retrieval top-k results | +| `webhooks.allow_http_targets` | `TMI_WEBHOOK_ALLOW_HTTP_TARGETS` | bool | `false` | hot | admin-only | no | db unless config explicit | Allow non-HTTPS webhook target URLs (intra-cluster use only) | +| `websocket.inactivity_timeout_seconds` | `TMI_WEBSOCKET_INACTIVITY_TIMEOUT_SECONDS` | int | `300` | hot | admin-only | no | db unless config explicit | WebSocket inactivity timeout in seconds | diff --git a/docs/superpowers/plans/2026-08-22-config-registry-phase-a.md b/docs/superpowers/plans/2026-08-22-config-registry-phase-a.md new file mode 100644 index 00000000..f79ce75c --- /dev/null +++ b/docs/superpowers/plans/2026-08-22-config-registry-phase-a.md @@ -0,0 +1,1746 @@ +# Config Registry (Phase A) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make one registry the single authoritative declaration of every TMI configuration setting, with guardrails that make an unclassified setting impossible rather than merely detectable. + +**Architecture:** A new `SettingDef` list in `internal/config` declares every setting exactly once — key, classification, type, default, description, and (for settings still delivered by file/env) the yaml path, env var, and a typed accessor. Existing consumers stop holding their own parallel lists: `GetMigratableSettings()` becomes a projection over the registry, `DefaultSystemSettings()` becomes a projection over its operational entries, and `genconfig`/`genconfigdocs` read the registry instead of the `Config` struct. Phase A changes **no runtime behavior** — it is a consolidation whose correctness is proven by equivalence tests. + +**Tech Stack:** Go 1.26 (go.mod pin), testify, reflection in tests only. + +**Spec:** `docs/superpowers/specs/2026-08-22-config-model-redesign-design.md` + +## Global Constraints + +- **No behavior change in Phase A.** Every existing test must pass unmodified. The only intended user-visible change is that `rate_limit.requests_per_minute` and `rate_limit.requests_per_hour` become classified (Task 5), which fixes #809. +- **No schema migration.** No new `system_settings` column, per the spec's non-goals. +- **Operational settings keep their config/env paths in Phase A.** They are marked `Transitional: true` and removed in Phase E. A validation rule that forbade them outright would fail the whole build on day one. +- **Reflection is permitted in tests only**, never in production code paths. +- **Logging:** `github.com/ericfitz/tmi/internal/slogging` only. Never the standard `log` package, never `fmt.Println`. +- **Never log setting values**, only keys and outcomes (`feedback_never_log_values`; the #794 leak). +- **Test command is `make test-unit`**, never `go test` directly. Single test: `make test-unit name=TestName`. +- **Lint gate:** `make lint` must report 0 issues before every commit. +- **Go style:** `gofmt`; imports grouped stdlib / external / internal; exported symbols get godoc comments. +- **SEM markers:** every new or behavior-changed function gets a `SEM@` marker line. Run `/sem-annotate --update ` *after* the code commit (`sem blame` cannot anchor a file not yet in HEAD). + +--- + +## Execution deviations + +This section records where the executed implementation deliberately departed from +the plan text below. The task text past this point is kept as originally written +for historical continuity; where it conflicts with what was actually built, this +section is authoritative. The full set of execution rulings (nineteen of them) is +recorded in the session ledger; the ledger is git-ignored scratch, so this section +is the durable record of the ones that matter to a future reader. + +1. **Emission stayed conditional, not unconditional.** Task 7 (below) called for + `GetMigratableSettings` to emit every declared key, including ones with empty + values, instead of omitting them. That would have broken two consumers: + `api/settings_service.go`'s `SeedDefaults` iterates + `config.DefaultOperationalSettings()` (itself derived from + `GetMigratableSettings()`), so unconditional emission would seed a new row into + `system_settings` on every fresh database; and + `internal/dbschema/system_setting_origin_backfill.go:86` uses the same set to + decide seeded-vs-explicit, which drives env-vs-database precedence on + **existing** databases. Instead, the conditionality was encoded explicitly as + `SettingDef.OmitWhenEmpty` (set on 45 defs), with `server.tls_cert_file` and + `server.tls_key_file` special-cased because their original guard tested + `server.tls_enabled` — a different field than the one being emitted. This is + pinned by `TestDefaultOperationalSettings_MatchesPreRegistryBaseline`. + +2. **`SeedableOperationalDefs()` filters on an explicit `Seeded` flag**, not on + "every operational def" as the plan assumed. Returning every operational def + would have seeded roughly 110 rows instead of the 9 that exist today. The + seeded set cannot be inferred from any other property on a def — + `session.timeout_minutes` and `features.saml_enabled` are both seeded *and* + config-delivered — so an explicit flag is the only rule that reproduces + today's set. + +3. **Task 3 and Task 4 were executed together, not sequentially.** Task 3's + bijection test walks every env-tagged `Config` field, which includes the + operational fields Task 4 declares, so Task 3's acceptance test could not pass + on its own until Task 4's work was also done. + +4. **The Task 6 coverage test keys on `YAMLPath` as well as `Key`, and uses no + allowlist.** Matching only on `Key` made correctly-declared defs whose `Key` + deliberately differs from their `YAMLPath` (the rename cases, e.g. + `features.saml_enabled` / `auth.saml.enabled`) look uncovered. See + `internal/config/registry_coverage_test.go`. + +--- + +### Task 1: The `SettingDef` type and registry container + +**Files:** +- Create: `internal/config/setting_def.go` +- Test: `internal/config/setting_def_test.go` + +**Interfaces:** +- Consumes: `ConfigClass`, `Category`, `Visibility`, `Mutability`, `Consumer`, `Delivery` from `internal/config/classification.go` (unchanged). +- Produces: `type SettingDef struct{...}`; `func AllSettingDefs() []SettingDef`; `func DefFor(key string) (SettingDef, bool)`; package-level `settingDefs []SettingDef` (empty in this task, populated in Tasks 3-5). + +- [ ] **Step 1: Write the failing test** + +```go +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefFor_ReturnsRegisteredDef(t *testing.T) { + defs := []SettingDef{ + { + Key: "test.example", + Type: "string", + Description: "an example", + Class: ConfigClass{ + Category: CategoryBootstrap, + Visibility: VisibilityInternal, + Consumers: []Consumer{ConsumerMonolith}, + }, + YAMLPath: "test.example", + EnvVar: "TMI_TEST_EXAMPLE", + Get: func(c *Config) string { return "v" }, + }, + } + idx := indexDefs(defs) + + got, ok := idx["test.example"] + require.True(t, ok) + assert.Equal(t, "string", got.Type) + assert.Equal(t, CategoryBootstrap, got.Class.Category) +} + +func TestDefFor_UnknownKeyReturnsFalse(t *testing.T) { + _, ok := DefFor("no.such.key.anywhere") + assert.False(t, ok) +} + +func TestAllSettingDefs_ReturnsACopy(t *testing.T) { + a := AllSettingDefs() + require.NotNil(t, a) + if len(a) == 0 { + t.Skip("registry not yet populated; covered from Task 3 onward") + } + a[0].Key = "mutated" + b := AllSettingDefs() + assert.NotEqual(t, "mutated", b[0].Key, "AllSettingDefs must not expose the backing array") +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `make test-unit name=TestDefFor` +Expected: FAIL — `undefined: SettingDef`, `undefined: indexDefs`, `undefined: DefFor`. + +- [ ] **Step 3: Write minimal implementation** + +```go +package config + +// SettingDef is the single authoritative declaration of one configuration +// setting. Every setting the server can read — bootstrap or operational, +// config-delivered or database-seeded — has exactly one SettingDef. +// +// Which fields are legal depends on Class.Category; ValidateSettingDefs +// enforces that (see setting_def_validation.go). +type SettingDef struct { + // Key is the canonical dotted setting key, e.g. "server.port". + Key string + // Class is the full classification: category, visibility, secrecy, + // mutability, consumers, delivery. + Class ConfigClass + // Type is the value's canonical type: "string", "bool", "int", + // "float", or "json". + Type string + // Description is human-readable and required; it feeds generated docs + // and the admin API. + Description string + // Default is the canonical string form of the compiled-in default. + // Required for operational settings, which have no config file to + // fall back to. + Default string + + // YAMLPath, EnvVar and Get are populated only for settings currently + // delivered by config file or environment: all bootstrap settings, plus + // operational settings still in transition (see Transitional). + YAMLPath string + EnvVar string + // Get extracts this setting's current value from a loaded Config as a + // canonical string. Nil for settings with no config-file path. + Get func(*Config) string + + // Transitional marks an operational setting that still has a config/env + // delivery path during the Phase A-E cutover described in the spec. + // Every Transitional entry is scheduled for removal in Phase E; the + // ratchet test in Task 9 prevents new ones from being added. + Transitional bool +} + +// IsSecret reports whether this setting's value must never appear in an API +// response or a log line. +// +// This answers the question only for STATICALLY DECLARED settings. The +// per-provider keys under auth.oauth.providers., auth.saml.providers. and +// content_oauth.providers. are generated per configured provider and have no +// SettingDef; for those, Class.Secret is deliberately false (a blanket true +// would mis-mask non-secret sub-keys like .client_id) and secrecy is carried +// only by the per-setting Secret flag that the provider helpers set on +// .client_secret, .sp_private_key and .idp_metadata_b64xml. Never reach for +// this method to decide secrecy for a provider key — use +// MigratableSetting.IsSecret(), which ORs both flags. +func (d SettingDef) IsSecret() bool { + return d.Class.Secret +} + +// settingDefs is the authoritative registry. Populated in Tasks 3-5. +var settingDefs []SettingDef + +// settingDefIndex is the by-key lookup, built once from settingDefs. +var settingDefIndex = indexDefs(settingDefs) + +// indexDefs builds a by-key lookup map from a slice of definitions. +func indexDefs(defs []SettingDef) map[string]SettingDef { + idx := make(map[string]SettingDef, len(defs)) + for _, d := range defs { + idx[d.Key] = d + } + return idx +} + +// DefFor returns the declaration for a setting key, and whether one exists. +func DefFor(key string) (SettingDef, bool) { + d, ok := settingDefIndex[key] + return d, ok +} + +// AllSettingDefs returns a copy of the registry, so callers cannot mutate it. +func AllSettingDefs() []SettingDef { + out := make([]SettingDef, len(settingDefs)) + copy(out, settingDefs) + return out +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `make test-unit name=TestDefFor` +Then: `make test-unit name=TestAllSettingDefs` +Expected: PASS (the third test skips while the registry is empty). + +- [ ] **Step 5: Lint and commit** + +```bash +make lint +git add internal/config/setting_def.go internal/config/setting_def_test.go +git commit -m "feat(config): add SettingDef, the single authoritative setting declaration" +``` + +--- + +### Task 2: Validation rules over `SettingDef` + +**Files:** +- Create: `internal/config/setting_def_validation.go` +- Test: `internal/config/setting_def_validation_test.go` +- Reference (do not modify yet): `internal/config/classification_validation.go` + +**Interfaces:** +- Consumes: `SettingDef`, `AllSettingDefs()` from Task 1. +- Produces: `func ValidateSettingDefs(defs []SettingDef) error` — returns an error naming every problem found, one per line, or nil. + +The rules below preserve every rule `ValidateClassifications` already enforces, and add the category-legality rules the spec requires. Port the existing rules rather than reinventing them: read `internal/config/classification_validation.go:13-89` and carry each one across. + +- [ ] **Step 1: Write the failing test** + +```go +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func validBootstrapDef() SettingDef { + return SettingDef{ + Key: "server.port", + Type: "string", + Description: "HTTP server port", + Class: ConfigClass{ + Category: CategoryBootstrap, + Visibility: VisibilityInternal, + Mutability: MutabilityStatic, + Consumers: []Consumer{ConsumerMonolith}, + }, + YAMLPath: "server.port", + EnvVar: "TMI_SERVER_PORT", + Get: func(c *Config) string { return c.Server.Port }, + } +} + +func validOperationalDef() SettingDef { + return SettingDef{ + Key: "ui.default_theme", + Type: "string", + Description: "Default UI theme", + Default: "auto", + Class: ConfigClass{ + Category: CategoryOperational, + Visibility: VisibilityPublic, + Mutability: MutabilityHot, + Delivery: &Delivery{}, + Consumers: []Consumer{ConsumerMonolith, ConsumerTMIUX}, + }, + } +} + +func TestValidateSettingDefs_AcceptsValidSet(t *testing.T) { + err := ValidateSettingDefs([]SettingDef{validBootstrapDef(), validOperationalDef()}) + assert.NoError(t, err) +} + +func TestValidateSettingDefs_RejectsUnclassified(t *testing.T) { + d := validBootstrapDef() + d.Class.Category = CategoryUnclassified + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unclassified") +} + +func TestValidateSettingDefs_RejectsDuplicateKeys(t *testing.T) { + err := ValidateSettingDefs([]SettingDef{validBootstrapDef(), validBootstrapDef()}) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate") +} + +func TestValidateSettingDefs_BootstrapRequiresEnvVarAndYAMLPath(t *testing.T) { + d := validBootstrapDef() + d.EnvVar = "" + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "EnvVar") + + d = validBootstrapDef() + d.YAMLPath = "" + err = ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "YAMLPath") +} + +func TestValidateSettingDefs_BootstrapRequiresGetter(t *testing.T) { + d := validBootstrapDef() + d.Get = nil + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Get") +} + +func TestValidateSettingDefs_BootstrapMustNotCarryDelivery(t *testing.T) { + d := validBootstrapDef() + d.Class.Delivery = &Delivery{} + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Delivery") +} + +func TestValidateSettingDefs_BootstrapMustNotBeTransitional(t *testing.T) { + d := validBootstrapDef() + d.Transitional = true + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Transitional") +} + +func TestValidateSettingDefs_OperationalRequiresDefault(t *testing.T) { + d := validOperationalDef() + d.Default = "" + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Default") +} + +func TestValidateSettingDefs_NonTransitionalOperationalMustHaveNoConfigPath(t *testing.T) { + d := validOperationalDef() + d.EnvVar = "TMI_UI_DEFAULT_THEME" + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "non-transitional") +} + +func TestValidateSettingDefs_TransitionalOperationalRequiresConfigPath(t *testing.T) { + d := validOperationalDef() + d.Transitional = true + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "transitional") +} + +func TestValidateSettingDefs_TransitionalOperationalAcceptsConfigPath(t *testing.T) { + d := validOperationalDef() + d.Transitional = true + d.EnvVar = "TMI_UI_DEFAULT_THEME" + d.YAMLPath = "ui.default_theme" + d.Get = func(c *Config) string { return "auto" } + assert.NoError(t, ValidateSettingDefs([]SettingDef{d})) +} + +func TestValidateSettingDefs_RejectsUnknownType(t *testing.T) { + d := validBootstrapDef() + d.Type = "widget" + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "type") +} + +func TestValidateSettingDefs_RequiresDescriptionAndConsumers(t *testing.T) { + d := validBootstrapDef() + d.Description = "" + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Description") + + d = validBootstrapDef() + d.Class.Consumers = nil + err = ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Consumers") +} + +func TestValidateSettingDefs_PublicCannotBeSecret(t *testing.T) { + d := validOperationalDef() + d.Class.Secret = true + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "public") +} + +func TestValidateSettingDefs_RequiredImpliesBootstrap(t *testing.T) { + d := validOperationalDef() + d.Class.Required = true + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Required") +} + +func TestValidateSettingDefs_SharedInvariantImpliesStamped(t *testing.T) { + d := validOperationalDef() + d.Class.Delivery = &Delivery{SharedInvariant: true} + d.Class.Consumers = []Consumer{ConsumerMonolith, ConsumerWorkerChunkEmbed} + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "StampedIntoEnvelope") +} + +func TestValidateSettingDefs_ReferenceImpliesSecret(t *testing.T) { + d := validBootstrapDef() + d.Class.ValueKind = ValueKindReference + d.Class.Secret = false + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "reference") +} + +func TestValidateSettingDefs_RegistryItselfIsValid(t *testing.T) { + assert.NoError(t, ValidateSettingDefs(AllSettingDefs())) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `make test-unit name=TestValidateSettingDefs` +Expected: FAIL — `undefined: ValidateSettingDefs`. + +- [ ] **Step 3: Write minimal implementation** + +```go +package config + +import ( + "fmt" + "strings" +) + +// validSettingTypes is the closed set of canonical value types. +var validSettingTypes = map[string]bool{ + "string": true, + "bool": true, + "int": true, + "float": true, + "json": true, +} + +// ValidateSettingDefs checks the registry's internal consistency and returns +// an error naming every problem found. It is the enforcement point for the +// rule that every setting must be classified: a definition whose Category is +// the zero value is rejected. +func ValidateSettingDefs(defs []SettingDef) error { + var problems []string + add := func(key, msg string) { + problems = append(problems, fmt.Sprintf("%s: %s", key, msg)) + } + + seen := make(map[string]bool, len(defs)) + for _, d := range defs { + if d.Key == "" { + problems = append(problems, "(empty key): a definition has no Key") + continue + } + if seen[d.Key] { + add(d.Key, "duplicate key in registry") + continue + } + seen[d.Key] = true + + c := d.Class + + if c.Category == CategoryUnclassified { + add(d.Key, "unclassified — Category is the zero value") + continue + } + if d.Description == "" { + add(d.Key, "empty Description") + } + if len(c.Consumers) == 0 { + add(d.Key, "no Consumers declared") + } + if !validSettingTypes[d.Type] { + add(d.Key, fmt.Sprintf("unknown type %q", d.Type)) + } + if c.ValueKind == ValueKindReference && !c.Secret { + add(d.Key, "ValueKindReference is only valid on a Secret setting") + } + if c.Visibility == VisibilityPublic && c.Secret { + add(d.Key, "a public setting must not be Secret") + } + if c.Required && c.Category != CategoryBootstrap { + add(d.Key, "Required implies bootstrap") + } + + hasConfigPath := d.YAMLPath != "" || d.EnvVar != "" || d.Get != nil + + switch c.Category { + case CategoryBootstrap: + if c.Delivery != nil { + add(d.Key, "bootstrap setting must not carry a Delivery") + } + if d.Transitional { + add(d.Key, "bootstrap setting must not be Transitional") + } + if d.YAMLPath == "" { + add(d.Key, "bootstrap setting must declare a YAMLPath") + } + if d.EnvVar == "" { + add(d.Key, "bootstrap setting must declare an EnvVar") + } + if d.Get == nil { + add(d.Key, "bootstrap setting must declare a Get accessor") + } + case CategoryOperational: + if d.Default == "" { + add(d.Key, "operational setting must declare a Default") + } + if c.Delivery != nil && c.Delivery.SharedInvariant { + if !c.Delivery.StampedIntoEnvelope { + add(d.Key, "SharedInvariant implies StampedIntoEnvelope") + } + if !hasWorkerConsumer(c.Consumers) { + add(d.Key, "SharedInvariant needs at least one worker consumer") + } + } + if d.Transitional { + if !hasConfigPath { + add(d.Key, "transitional operational setting must keep its YAMLPath, EnvVar and Get until Phase E") + } + if d.YAMLPath == "" || d.EnvVar == "" || d.Get == nil { + add(d.Key, "transitional operational setting needs all of YAMLPath, EnvVar and Get") + } + } else if hasConfigPath { + add(d.Key, "non-transitional operational setting must have no YAMLPath, EnvVar or Get") + } + } + } + + if len(problems) == 0 { + return nil + } + return fmt.Errorf("invalid setting definitions:\n %s", strings.Join(problems, "\n ")) +} + +// hasWorkerConsumer reports whether any consumer is a worker process. +func hasWorkerConsumer(consumers []Consumer) bool { + for _, c := range consumers { + if c == ConsumerWorkerExtractor || c == ConsumerWorkerChunkEmbed { + return true + } + } + return false +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `make test-unit name=TestValidateSettingDefs` +Expected: PASS, all cases. + +- [ ] **Step 5: Lint and commit** + +```bash +make lint +git add internal/config/setting_def_validation.go internal/config/setting_def_validation_test.go +git commit -m "feat(config): validate SettingDef category legality, including the transition marker" +``` + +--- + +### Task 3: Port the bootstrap settings into the registry + +**Files:** +- Create: `internal/config/setting_defs_bootstrap.go` +- Test: `internal/config/setting_defs_bijection_test.go` +- Read for source data: `internal/config/config.go` (env/yaml tags), `internal/config/classification_registry.go:100-282` (classes), `internal/config/migratable_settings.go` (descriptions, types, accessors) + +**Interfaces:** +- Consumes: `SettingDef` (Task 1), `ValidateSettingDefs` (Task 2). +- Produces: `var bootstrapSettingDefs []SettingDef`, appended into `settingDefs`. + +The bijection test is the specification for this task: it enumerates exactly which keys are missing or extra, so the port is complete when the test passes. + +- [ ] **Step 1: Write the failing test** + +```go +package config + +import ( + "reflect" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// envTaggedKeys walks the Config struct and returns every yaml path that +// carries an env tag, as "a.b.c" joined from the yaml tags along the path. +// Reflection is used here deliberately and only in tests. +func envTaggedKeys(t *testing.T) map[string]string { + t.Helper() + out := map[string]string{} + + var walk func(rt reflect.Type, prefix []string) + walk = func(rt reflect.Type, prefix []string) { + if rt.Kind() == reflect.Ptr { + rt = rt.Elem() + } + if rt.Kind() != reflect.Struct { + return + } + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + yamlTag := strings.Split(f.Tag.Get("yaml"), ",")[0] + if yamlTag == "-" { + continue + } + path := prefix + if yamlTag != "" { + path = append(append([]string{}, prefix...), yamlTag) + } + if env := f.Tag.Get("env"); env != "" { + out[strings.Join(path, ".")] = env + } + ft := f.Type + if ft.Kind() == reflect.Ptr { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct { + walk(ft, path) + } + } + } + walk(reflect.TypeOf(Config{}), nil) + return out +} + +func TestBootstrapDefs_BijectWithConfigStructEnvTags(t *testing.T) { + structKeys := envTaggedKeys(t) + + declared := map[string]string{} + for _, d := range AllSettingDefs() { + if d.YAMLPath == "" { + continue // no config path: database-only operational setting + } + declared[d.YAMLPath] = d.EnvVar + } + + var missing, extra []string + for k := range structKeys { + if _, ok := declared[k]; !ok { + missing = append(missing, k) + } + } + for k := range declared { + if _, ok := structKeys[k]; !ok { + extra = append(extra, k) + } + } + sort.Strings(missing) + sort.Strings(extra) + + assert.Empty(t, missing, "Config struct fields with an env tag that have no SettingDef") + assert.Empty(t, extra, "SettingDefs claiming a config path that the Config struct does not bind") +} + +func TestBootstrapDefs_EnvVarNamesMatchStructTags(t *testing.T) { + structKeys := envTaggedKeys(t) + for _, d := range AllSettingDefs() { + if d.YAMLPath == "" { + continue + } + want, ok := structKeys[d.YAMLPath] + if !ok { + continue // reported by the bijection test + } + assert.Equal(t, want, d.EnvVar, + "SettingDef %q declares EnvVar %q but the Config struct binds %q", + d.Key, d.EnvVar, want) + } +} + +func TestBootstrapDefs_GetReturnsConfiguredValue(t *testing.T) { + c := &Config{} + c.Server.Port = "9999" + + d, ok := DefFor("server.port") + assert.True(t, ok, "server.port must be declared") + if ok { + assert.Equal(t, "9999", d.Get(c)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `make test-unit name=TestBootstrapDefs` +Expected: FAIL. `TestBootstrapDefs_BijectWithConfigStructEnvTags` lists every env-tagged key as missing — that list is the work queue for Step 3. + +- [ ] **Step 3: Write the implementation** + +Create `internal/config/setting_defs_bootstrap.go` declaring one `SettingDef` per bootstrap key. Source each field from the existing code rather than inventing it: + +- `Key` and `YAMLPath` — the dotted yaml path (they are equal for bootstrap settings). +- `EnvVar` — copy verbatim from the `env:` struct tag in `internal/config/config.go`. **Do not derive it**; the mapping is irregular (`server.disable_rate_limiting` binds `TMI_DISABLE_RATE_LIMITING`, not `TMI_SERVER_DISABLE_RATE_LIMITING`), and a derived name marks a struct default as explicit, which is exactly the class of bug #794 fixed. +- `Class` — copy from `exactClassifications` in `classification_registry.go`. +- `Type` and `Description` — copy from the corresponding entry in `migratable_settings.go`. +- `Get` — a closure returning the canonical string form, matching how `migratable_settings.go` formats that field (`strconv.FormatBool`, `strconv.Itoa`, `.String()` for durations, `json.Marshal` for slices). + +The file's shape, with three worked examples covering the three formatting cases: + +```go +package config + +import ( + "encoding/json" + "strconv" +) + +// bootstrapSettingDefs declares every setting delivered by config file and +// environment. Bootstrap settings are never stored in the database. +var bootstrapSettingDefs = []SettingDef{ + { + Key: "server.port", + YAMLPath: "server.port", + EnvVar: "TMI_SERVER_PORT", + Type: "string", + Description: "HTTP server port", + Class: bootstrapClass(false, VisibilityInternal, false), + Get: func(c *Config) string { return c.Server.Port }, + }, + { + Key: "server.tls_enabled", + YAMLPath: "server.tls_enabled", + EnvVar: "TMI_SERVER_TLS_ENABLED", + Type: "bool", + Description: "TLS enabled", + Class: bootstrapClass(false, VisibilityInternal, false), + Get: func(c *Config) string { return strconv.FormatBool(c.Server.TLSEnabled) }, + }, + { + Key: "server.cors.allowed_origins", + YAMLPath: "server.cors.allowed_origins", + EnvVar: "TMI_CORS_ALLOWED_ORIGINS", + Type: "json", + Description: "CORS allowed origins", + Class: bootstrapClass(false, VisibilityInternal, false), + Get: func(c *Config) string { + b, err := json.Marshal(c.Server.CORS.AllowedOrigins) + if err != nil { + return "[]" + } + return string(b) + }, + }, + // ... one entry per remaining env-tagged bootstrap key; the bijection + // test in setting_defs_bijection_test.go names any that are missing. +} +``` + +Then register them by changing the `settingDefs` declaration in `setting_def.go`: + +```go +// settingDefs is the authoritative registry. +var settingDefs = concatDefs(bootstrapSettingDefs) + +// concatDefs joins definition groups into the single registry slice. +func concatDefs(groups ...[]SettingDef) []SettingDef { + var out []SettingDef + for _, g := range groups { + out = append(out, g...) + } + return out +} +``` + +Note `settingDefIndex` is initialised from `settingDefs` at package init; because Go initialises package-level variables in dependency order, no change is needed there. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make test-unit name=TestBootstrapDefs` +Expected: PASS — both `missing` and `extra` empty. +Then: `make test-unit name=TestValidateSettingDefs_RegistryItselfIsValid` +Expected: PASS. + +- [ ] **Step 5: Lint and commit** + +```bash +make lint +git add internal/config/setting_defs_bootstrap.go internal/config/setting_defs_bijection_test.go internal/config/setting_def.go +git commit -m "feat(config): declare every bootstrap setting in the registry, bijective with the Config struct" +``` + +--- + +### Task 4: Port the operational settings as transitional + +**Files:** +- Create: `internal/config/setting_defs_operational.go` +- Modify: `internal/config/setting_def.go` (add the group to `concatDefs`) +- Test: `internal/config/setting_defs_operational_test.go` +- Read for source data: `internal/config/classification_registry.go:100-282`, `internal/config/migratable_settings.go` + +**Interfaces:** +- Consumes: `SettingDef` (Task 1), `ValidateSettingDefs` (Task 2), `concatDefs` (Task 3). +- Produces: `var operationalSettingDefs []SettingDef`. + +Every setting classified `operationalClass(...)` today still has a `Config` struct field, so each is declared `Transitional: true` with its `YAMLPath`, `EnvVar` and `Get` retained. Phase E removes them. + +- [ ] **Step 1: Write the failing test** + +```go +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOperationalDefs_KnownKeysAreDeclaredOperational(t *testing.T) { + // A representative sample across the operational sections. Full coverage + // is proven by TestRegistry_CoversEveryReachableKey in Task 6. + for _, key := range []string{ + "auth.everyone_is_a_reviewer", + "auth.oauth.client_callback_allowlist", + "features.saml_enabled", + "websocket.inactivity_timeout_seconds", + "operator.name", + "administrators", + "observability.enabled", + "ssrf.webhook.allowlist", + "webhooks.allow_http_targets", + "timmy.embedding_dimension", + } { + d, ok := DefFor(key) + require.True(t, ok, "%s must be declared in the registry", key) + assert.Equal(t, CategoryOperational, d.Class.Category, "%s must be operational", key) + assert.NotEmpty(t, d.Default, "%s must declare a Default", key) + } +} + +func TestOperationalDefs_AreTransitionalWhileConfigDelivered(t *testing.T) { + d, ok := DefFor("auth.everyone_is_a_reviewer") + require.True(t, ok) + assert.True(t, d.Transitional, "operational settings still have a config path in Phase A") + assert.NotEmpty(t, d.EnvVar) + assert.NotNil(t, d.Get) +} + +func TestOperationalDefs_DeclareMutability(t *testing.T) { + // features.saml_enabled gates SAML manager construction at startup, so a + // database edit cannot take effect without a restart. The spec makes + // Mutability load-bearing; this pins the known case. + d, ok := DefFor("features.saml_enabled") + require.True(t, ok) + assert.Equal(t, MutabilityStatic, d.Class.Mutability, + "features.saml_enabled gates startup wiring and is restart-required") +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `make test-unit name=TestOperationalDefs` +Expected: FAIL — the keys are not yet declared. + +- [ ] **Step 3: Write the implementation** + +Create `internal/config/setting_defs_operational.go`, one entry per key currently classified with `operationalClass(...)` or `sharedEmbeddingClass(...)`. Two worked examples: + +```go +package config + +import "strconv" + +// operationalSettingDefs declares every database-backed setting. Entries +// marked Transitional still have a config/env delivery path, removed in +// Phase E of the config model redesign. +var operationalSettingDefs = []SettingDef{ + { + Key: "auth.everyone_is_a_reviewer", + YAMLPath: "auth.everyone_is_a_reviewer", + EnvVar: "TMI_EVERYONE_IS_A_REVIEWER", + Type: "bool", + Description: "Grant every authenticated user the reviewer role", + Default: "false", + Transitional: true, + Class: operationalClass(VisibilityAdminOnly, false), + Get: func(c *Config) string { return strconv.FormatBool(c.Auth.EveryoneIsAReviewer) }, + }, + { + Key: "features.saml_enabled", + YAMLPath: "auth.saml.enabled", + EnvVar: "TMI_SAML_ENABLED", + Type: "bool", + Description: "Enable SAML authentication", + Default: "false", + Transitional: true, + Class: ConfigClass{ + Category: CategoryOperational, + Visibility: VisibilityPublic, + Mutability: MutabilityStatic, // gates SAML manager construction at startup + Delivery: &Delivery{}, + Consumers: []Consumer{ConsumerMonolith, ConsumerTMIUX}, + }, + Get: func(c *Config) string { return strconv.FormatBool(c.Auth.SAML.Enabled) }, + }, + // ... one entry per remaining operational key. +} +``` + +Set `Mutability` deliberately per entry: `MutabilityHot` where the value is re-read at use time, `MutabilityStatic` where it is captured at startup. When unsure, read the consuming code before choosing; do not copy the value from `operationalClass`, whose default was never load-bearing. + +Then extend the registry in `setting_def.go`: + +```go +var settingDefs = concatDefs(bootstrapSettingDefs, operationalSettingDefs) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make test-unit name=TestOperationalDefs` +Then: `make test-unit name=TestValidateSettingDefs_RegistryItselfIsValid` +Then: `make test-unit name=TestBootstrapDefs` +Expected: PASS. + +- [ ] **Step 5: Lint and commit** + +```bash +make lint +git add internal/config/setting_defs_operational.go internal/config/setting_defs_operational_test.go internal/config/setting_def.go +git commit -m "feat(config): declare operational settings in the registry, marked transitional" +``` + +--- + +### Task 5: Declare the database-seeded keys and make `DefaultSystemSettings()` a projection + +**Files:** +- Modify: `internal/config/setting_defs_operational.go` +- Modify: `api/models/system_setting.go:106-165` (`DefaultSystemSettings`) +- Create: `internal/config/seed_projection.go` +- Test: `internal/config/seed_projection_test.go` +- Test: `api/models/system_setting_test.go` + +**Interfaces:** +- Consumes: `AllSettingDefs()` (Task 1), `operationalSettingDefs` (Task 4). +- Produces: `func SeedableOperationalDefs() []SettingDef` — the operational entries that should be seeded into `system_settings`, sorted by key. + +This is the task that fixes **#809**: `rate_limit.requests_per_minute` and `rate_limit.requests_per_hour` are seeded today but classified nowhere, so `GET`/`DELETE /admin/settings/{key}` 404s on keys the list endpoint shows. + +- [ ] **Step 1: Write the failing test** + +```go +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSeedableOperationalDefs_IncludesPreviouslyUnclassifiedKeys(t *testing.T) { + // #809: these two were seeded into system_settings but had no + // classification entry, so they resolved to VisibilityInternal and 404'd + // on GET/DELETE while appearing in the LIST response. + for _, key := range []string{ + "rate_limit.requests_per_minute", + "rate_limit.requests_per_hour", + } { + d, ok := DefFor(key) + require.True(t, ok, "%s must be declared", key) + assert.Equal(t, CategoryOperational, d.Class.Category) + assert.Equal(t, VisibilityAdminOnly, d.Class.Visibility, + "%s is not consumed by tmi-ux, so admin-only rather than public", key) + assert.False(t, d.Transitional, "%s has no config path", key) + } +} + +func TestSeedableOperationalDefs_AreSortedAndNonEmpty(t *testing.T) { + defs := SeedableOperationalDefs() + require.NotEmpty(t, defs) + for i := 1; i < len(defs); i++ { + assert.Less(t, defs[i-1].Key, defs[i].Key, "SeedableOperationalDefs must be sorted by key") + } + for _, d := range defs { + assert.Equal(t, CategoryOperational, d.Class.Category) + assert.NotEmpty(t, d.Default) + } +} +``` + +And in `api/models/system_setting_test.go`: + +```go +func TestDefaultSystemSettings_MatchesRegistryProjection(t *testing.T) { + defs := config.SeedableOperationalDefs() + seeds := DefaultSystemSettings() + + require.Equal(t, len(defs), len(seeds), + "DefaultSystemSettings must be a projection of the registry, not a parallel list") + + byKey := map[string]SystemSetting{} + for _, s := range seeds { + byKey[string(s.SettingKey)] = s + } + for _, d := range defs { + s, ok := byKey[d.Key] + require.True(t, ok, "registry declares %s but DefaultSystemSettings does not seed it", d.Key) + assert.Equal(t, d.Default, s.Value, "seed value for %s must be the registry Default", d.Key) + assert.Equal(t, d.Description, s.Description.String, "seed description for %s", d.Key) + } +} + +func TestDefaultSystemSettings_SeedsPreviouslyUnclassifiedRateLimitKeys(t *testing.T) { + keys := map[string]bool{} + for _, s := range DefaultSystemSettings() { + keys[string(s.SettingKey)] = true + } + assert.True(t, keys["rate_limit.requests_per_minute"]) + assert.True(t, keys["rate_limit.requests_per_hour"]) +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make test-unit name=TestSeedableOperationalDefs` +Expected: FAIL — `rate_limit.*` not declared; `SeedableOperationalDefs` undefined. +Run: `make test-unit name=TestDefaultSystemSettings` +Expected: FAIL — counts differ. + +- [ ] **Step 3: Write the implementation** + +Add the nine keys currently seeded by `DefaultSystemSettings()` to `operationalSettingDefs`, non-transitional (they have no `Config` struct field), preserving each existing default value and description exactly so no seeded database changes: + +```go + { + Key: "rate_limit.requests_per_minute", + Type: "int", + Description: "Maximum API requests per minute per user", + Default: "100", + Class: operationalClass(VisibilityAdminOnly, false), + }, + { + Key: "rate_limit.requests_per_hour", + Type: "int", + Description: "Maximum API requests per hour per user", + Default: "1000", + Class: operationalClass(VisibilityAdminOnly, false), + }, + { + Key: "ui.default_theme", + Type: "string", + Description: "Default UI theme (auto, light, dark)", + Default: "auto", + Class: operationalClass(VisibilityPublic, false, ConsumerMonolith, ConsumerTMIUX), + }, + // ... and the remaining seeded keys: websocket.max_participants, + // upload.max_file_size_mb, features.webhooks_enabled, + // features.websocket_enabled. Note session.timeout_minutes and + // features.saml_enabled are already declared (Task 4) — do not duplicate + // them; the duplicate-key validation rule will catch it if you do. +``` + +Create `internal/config/seed_projection.go`: + +```go +package config + +import "sort" + +// SeedableOperationalDefs returns the operational settings that should be +// seeded into system_settings on database initialisation, sorted by key. +// +// This is the single source for the seed list. DefaultSystemSettings() in +// api/models projects it, rather than maintaining a parallel list — the +// parallel list is what left rate_limit.* seeded but unclassified (#809). +func SeedableOperationalDefs() []SettingDef { + var out []SettingDef + for _, d := range settingDefs { + if d.Class.Category == CategoryOperational { + out = append(out, d) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + return out +} +``` + +Rewrite `DefaultSystemSettings()` in `api/models/system_setting.go` as a projection: + +```go +// DefaultSystemSettings returns the default system settings seeded when the +// database is initialised. It projects the config package's registry rather +// than maintaining a parallel list. +func DefaultSystemSettings() []SystemSetting { + desc := func(s string) NullableDBText { return NullableDBText{String: s, Valid: true} } + + defs := config.SeedableOperationalDefs() + out := make([]SystemSetting, 0, len(defs)) + for _, d := range defs { + out = append(out, SystemSetting{ + SettingKey: DBVarchar(d.Key), + Value: d.Default, + SettingType: settingTypeFor(d.Type), + Description: desc(d.Description), + }) + } + return out +} + +// settingTypeFor maps a registry type name to the stored setting_type. +func settingTypeFor(t string) string { + switch t { + case "bool": + return SystemSettingTypeBool + case "int": + return SystemSettingTypeInt + case "float": + return SystemSettingTypeFloat + case "json": + return SystemSettingTypeJSON + default: + return SystemSettingTypeString + } +} +``` + +Check the exact constant names and the `SettingKey`/`SettingType` field types against `api/models/system_setting.go:1-60` before writing; adjust the conversions to match. If `SystemSettingTypeFloat` or `SystemSettingTypeJSON` does not exist, map those types to the string constant and note it in the commit message. + +Watch the import direction: `api/models` may import `internal/config`, but not the reverse. Verify with `go build ./...` that no import cycle results; if one does, move `SeedableOperationalDefs` consumption into a small adapter in `api/models` rather than inverting the dependency. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `make test-unit name=TestSeedableOperationalDefs` +Run: `make test-unit name=TestDefaultSystemSettings` +Run: `make build-server` +Run: `make test-unit` +Expected: PASS, full suite green (2678 tests as of 1.8.22). + +- [ ] **Step 5: Lint and commit** + +```bash +make lint +git add internal/config/setting_defs_operational.go internal/config/seed_projection.go internal/config/seed_projection_test.go api/models/system_setting.go api/models/system_setting_test.go +git commit -m "fix(config): classify rate_limit.* and project DefaultSystemSettings from the registry + +The seed list was a hand-kept parallel list, so rate_limit.requests_per_minute +and rate_limit.requests_per_hour were seeded into system_settings while having +no classification entry. Unclassified resolves to VisibilityInternal, so +GET/DELETE /admin/settings/{key} returned 404 for keys the LIST endpoint +displayed. + +Fixes #809" +``` + +--- + +### Task 6: The total-coverage guardrail + +**Files:** +- Create: `internal/config/registry_coverage_test.go` + +**Interfaces:** +- Consumes: `AllSettingDefs()`, `DefFor()` (Task 1), `SeedableOperationalDefs()` (Task 5), `exactClassifications` and `prefixClassifications` (existing, `classification_registry.go`). + +This is the test that would have caught #809, and the enforcement point for the spec's goal 6. + +- [ ] **Step 1: Write the test** + +```go +package config + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestRegistry_CoversEveryReachableKey asserts that every setting key the +// server can reach — from the Config struct, the classification registry, or +// the database seed list — has a SettingDef with a real Category. +// +// The previous guardrail could not do this: ValidateClassifications took +// whatever slice it was handed, in practice GetMigratableSettings(), which by +// construction contains only keys that already have a Config struct field, +// and which emits conditionally so the set changed with runtime values. +func TestRegistry_CoversEveryReachableKey(t *testing.T) { + declared := map[string]bool{} + for _, d := range AllSettingDefs() { + declared[d.Key] = true + } + + reachable := map[string]string{} // key -> where it came from + + for k := range envTaggedKeys(t) { + reachable[k] = "Config struct env tag" + } + for k := range exactClassifications { + reachable[k] = "classification registry" + } + for _, d := range SeedableOperationalDefs() { + reachable[d.Key] = "database seed list" + } + + var uncovered []string + for k, src := range reachable { + if !declared[k] { + uncovered = append(uncovered, k+" (from "+src+")") + } + } + sort.Strings(uncovered) + + assert.Empty(t, uncovered, + "every reachable setting key must have a SettingDef; add one to internal/config/setting_defs_*.go") +} + +func TestRegistry_NoKeyIsDeclaredTwice(t *testing.T) { + seen := map[string]int{} + for _, d := range AllSettingDefs() { + seen[d.Key]++ + } + var dupes []string + for k, n := range seen { + if n > 1 { + dupes = append(dupes, k) + } + } + sort.Strings(dupes) + assert.Empty(t, dupes, "a setting must be declared exactly once") +} + +func TestRegistry_EveryDefHasANonZeroCategory(t *testing.T) { + for _, d := range AllSettingDefs() { + assert.NotEqual(t, CategoryUnclassified, d.Class.Category, + "%s has the zero Category", d.Key) + } +} + +func TestRegistry_PassesValidation(t *testing.T) { + assert.NoError(t, ValidateSettingDefs(AllSettingDefs())) +} +``` + +- [ ] **Step 2: Run tests** + +Run: `make test-unit name=TestRegistry` +Expected: PASS if Tasks 3-5 are complete. Any failure names the exact missing keys — add a `SettingDef` for each and re-run. + +- [ ] **Step 3: Verify the guardrail actually bites** + +Temporarily add a seeded key with no `SettingDef` — append to `operationalSettingDefs` a `SettingDef` with `Key: "temp.guardrail.probe"` and then delete it from the registry while adding `"temp.guardrail.probe"` to `exactClassifications`. Run `make test-unit name=TestRegistry_CoversEveryReachableKey` and confirm it FAILS naming that key. Revert the probe. + +This step is not optional: a coverage test that cannot fail is worse than none, and the previous guardrail's whole defect was that it validated a set that excluded the problem. + +- [ ] **Step 4: Lint and commit** + +```bash +make lint +git add internal/config/registry_coverage_test.go +git commit -m "test(config): assert every reachable setting key has a classification" +``` + +--- + +### Task 7: Project `GetMigratableSettings()` from the registry + +**Files:** +- Modify: `internal/config/migratable_settings.go:60-105` (`GetMigratableSettings`) and delete the per-section builders it calls +- Test: `internal/config/migratable_settings_equivalence_test.go` + +**Interfaces:** +- Consumes: `AllSettingDefs()`, `SettingDef.Get` (Tasks 1, 3, 4). +- Produces: `GetMigratableSettings()` with an unchanged signature — `func (c *Config) GetMigratableSettings() []MigratableSetting`. + +The equivalence test is written **before** the rewrite and pins current output, so the refactor is provably behaviour-preserving. + +- [ ] **Step 1: Write the equivalence test against current behaviour** + +```go +package config + +import ( + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// sampleConfig returns a Config with enough fields populated to exercise the +// conditional emission paths in the pre-refactor implementation. +func sampleConfig() *Config { + c := &Config{} + c.Server.Port = "8080" + c.Server.Interface = "0.0.0.0" + c.Server.BaseURL = "https://api.example.test" + c.Server.TLSEnabled = true + c.Server.TLSCertFile = "/tmp/cert.pem" + c.Server.TLSKeyFile = "/tmp/key.pem" + c.Server.CORS.AllowedOrigins = []string{"https://ui.example.test"} + return c +} + +func keysOf(settings []MigratableSetting) []string { + out := make([]string, 0, len(settings)) + for _, s := range settings { + out = append(out, s.Key) + } + sort.Strings(out) + return out +} + +func TestGetMigratableSettings_EmitsEveryDeclaredConfigPathKey(t *testing.T) { + c := sampleConfig() + got := keysOf(c.GetMigratableSettings()) + + var want []string + for _, d := range AllSettingDefs() { + if d.Get != nil { + want = append(want, d.Key) + } + } + sort.Strings(want) + + assert.Equal(t, want, got, + "GetMigratableSettings must emit exactly the registry's config-path keys") +} + +func TestGetMigratableSettings_ValuesComeFromConfig(t *testing.T) { + c := sampleConfig() + byKey := map[string]MigratableSetting{} + for _, s := range c.GetMigratableSettings() { + byKey[s.Key] = s + } + + require.Contains(t, byKey, "server.port") + assert.Equal(t, "8080", byKey["server.port"].Value) + assert.Equal(t, "TMI_SERVER_PORT", byKey["server.port"].EnvVar) + + require.Contains(t, byKey, "server.tls_enabled") + assert.Equal(t, "true", byKey["server.tls_enabled"].Value) + + require.Contains(t, byKey, "server.cors.allowed_origins") + assert.Equal(t, `["https://ui.example.test"]`, byKey["server.cors.allowed_origins"].Value) +} + +func TestGetMigratableSettings_CarriesClassAndSecrecy(t *testing.T) { + c := sampleConfig() + for _, s := range c.GetMigratableSettings() { + // Per-provider keys are generated, not statically declared, and have + // no SettingDef by design. Their secrecy lives on the per-setting + // Secret flag rather than on Class.Secret, so comparing them against + // a SettingDef would be both impossible and wrong. + if isGeneratedProviderKey(s.Key) { + assert.True(t, s.Class.Category != CategoryUnclassified, + "generated provider key %s must still be prefix-classified", s.Key) + continue + } + d, ok := DefFor(s.Key) + require.True(t, ok, "emitted key %s has no SettingDef", s.Key) + assert.Equal(t, d.Class.Category, s.Class.Category, "class mismatch for %s", s.Key) + assert.Equal(t, d.IsSecret(), s.IsSecret(), "secrecy mismatch for %s", s.Key) + } +} + +// isGeneratedProviderKey reports whether a key comes from the per-provider +// generators rather than from a static SettingDef. +func isGeneratedProviderKey(key string) bool { + for _, p := range []string{ + "auth.oauth.providers.", + "auth.saml.providers.", + "content_oauth.providers.", + } { + if strings.HasPrefix(key, p) { + return true + } + } + return false +} + +func TestGetMigratableSettings_ExplicitTracksEnvAndFile(t *testing.T) { + t.Setenv("TMI_SERVER_PORT", "9090") + c := sampleConfig() + for _, s := range c.GetMigratableSettings() { + if s.Key == "server.port" { + assert.Equal(t, "environment", s.Source) + assert.True(t, s.Explicit, "an env-set key must be Explicit") + } + } +} +``` + +- [ ] **Step 2: Run the tests against the current implementation** + +Run: `make test-unit name=TestGetMigratableSettings` +Expected: `TestGetMigratableSettings_EmitsEveryDeclaredConfigPathKey` FAILS, because the current implementation emits `server.base_url`, `server.tls_cert_file`, `server.tls_key_file` and `server.cors.allowed_origins` only when their values are non-empty, whereas the registry declares them unconditionally. + +**Executed differently — see "Execution deviations" above.** Unconditional emission was not adopted; it was replaced with the explicit `SettingDef.OmitWhenEmpty` flag, for the reasons given there. + +**Record the diff before changing anything.** Conditional emission is a real behaviour difference, not a test bug: a key that vanishes when empty is why `ValidateClassifications` saw a different set on every boot. The other tests should pass, confirming values, classes and explicitness are already equivalent. + +- [ ] **Step 3: Rewrite `GetMigratableSettings` as a projection** + +**Executed differently — see "Execution deviations" above.** The code sample +below is the plan's original proposal and was not what was built; the shipped +`GetMigratableSettings` honors `SettingDef.OmitWhenEmpty` instead of emitting +every key unconditionally. + +```go +// GetMigratableSettings returns every setting that has a config-file or +// environment delivery path, with its current value read from this Config. +// +// This projects the registry (setting_defs_*.go) rather than maintaining a +// parallel list. Emission is unconditional: a key with an empty value is +// emitted with an empty value, rather than being omitted. The previous +// implementation omitted several keys when unset, which made the set of +// settings vary with runtime values and left the classification guardrail +// validating a different set on every boot. +func (c *Config) GetMigratableSettings() []MigratableSetting { + defs := AllSettingDefs() + settings := make([]MigratableSetting, 0, len(defs)) + + for _, d := range defs { + if d.Get == nil { + continue // database-only: no config path to read from + } + settings = append(settings, MigratableSetting{ + Key: d.Key, + Value: d.Get(c), + Type: d.Type, + Description: d.Description, + Secret: d.Class.Secret, + Source: settingSource(d.EnvVar), + EnvVar: d.EnvVar, + Class: d.Class, + }) + } + + for i := range settings { + settings[i].Explicit = settings[i].Source == "environment" || + c.explicitFileKeys[settings[i].Key] + } + + return settings +} +``` + +Delete the per-section builders this replaces (`getMigratableServerSettings`, `getMigratableAuthSettings`, and their siblings) along with any helper now unreferenced. Keep `settingSource`, `MigratableSetting`, and `IsSecret`. + +The provider subtrees (`auth.oauth.providers.*`, `auth.saml.providers.*`, `content_oauth.providers.*`) are generated per configured provider rather than declared statically. Keep their existing generator functions and append their output after the loop above; they are prefix-classified and out of scope for Phase A. **Preserve the per-setting `Secret` flag those helpers set on `.client_secret`, `.sp_private_key` and `.idp_metadata_b64xml`** — `Class.Secret` is deliberately false for those prefixes, so dropping the per-key flag would expose real OAuth client secrets. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `make test-unit name=TestGetMigratableSettings` +Run: `make test-unit` +Expected: PASS, full suite. + +If a pre-existing test now fails because a key is emitted that previously was not, that is the intended behaviour change — update the test and note it in the commit message. If a test fails because a *value* differs, stop: that is a regression, not a behaviour change. + +- [ ] **Step 5: Lint and commit** + +```bash +make lint +git add internal/config/migratable_settings.go internal/config/migratable_settings_equivalence_test.go +git commit -m "refactor(config): project GetMigratableSettings from the registry + +Emission is now unconditional. Keys that were previously omitted when their +value was empty (server.base_url, the TLS file paths, CORS origins) are always +emitted, so the set of settings no longer varies with runtime values." +``` + +**Executed differently — see "Execution deviations" above.** This proposed +commit message describes the plan's original intent, not the shipped commit: +emission stayed conditional via `SettingDef.OmitWhenEmpty` rather than +becoming unconditional. + +--- + +### Task 8: Re-point `genconfig` and `genconfigdocs` at the registry + +**Files:** +- Modify: `cmd/genconfig/main.go` +- Modify: `cmd/genconfigdocs/main.go` +- Test: `cmd/genconfig/main_test.go` +- Test: `cmd/genconfigdocs/main_test.go` + +**Interfaces:** +- Consumes: `config.AllSettingDefs()` (Task 1). +- Produces: no new exported API; generated output must be equivalent to today's. + +Read both files first — they generate `config-example.yml` and the config reference documentation from the `Config` struct today. The generated config reference doubles as the `TMI_*` env-var allowlist, so an omitted env var silently narrows it. + +- [ ] **Step 1: Write the failing test** + +```go +package main + +import ( + "strings" + "testing" + + "github.com/ericfitz/tmi/internal/config" + "github.com/stretchr/testify/assert" +) + +func TestGenerated_CoversEveryBootstrapEnvVar(t *testing.T) { + out := generate() // the package's existing generation entry point + + var missing []string + for _, d := range config.AllSettingDefs() { + if d.EnvVar == "" { + continue + } + if !strings.Contains(out, d.EnvVar) { + missing = append(missing, d.EnvVar) + } + } + assert.Empty(t, missing, + "generated output must name every declared env var; it doubles as the TMI_* allowlist") +} + +func TestGenerated_OmitsDatabaseOnlySettings(t *testing.T) { + out := generate() + for _, d := range config.AllSettingDefs() { + if d.Class.Category == config.CategoryOperational && !d.Transitional { + assert.NotContains(t, out, d.Key, + "%s is database-only and must not appear in a config file template", d.Key) + } + } +} +``` + +Adapt `generate()` to whatever the entry point is actually named in each command; if generation currently writes straight to a file, extract a function returning the rendered string first, in this same task, and have `main` call it. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `make test-unit name=TestGenerated` +Expected: FAIL — either `generate` is undefined (extract it first) or database-only keys appear in the output. + +- [ ] **Step 3: Rewrite generation to walk the registry** + +Replace the `Config`-struct reflection walk with a loop over `config.AllSettingDefs()`, emitting an entry for each def where `d.YAMLPath != ""` (bootstrap plus transitional operational), grouped by the first dotted segment of `YAMLPath` and sorted by key within each group. Emit `d.Description` as the comment, `d.EnvVar` as the documented override, and `d.Default` as the sample value. + +Skip defs with `Class.Category == CategoryOperational && !Transitional` entirely: they have no config-file representation. + +- [ ] **Step 4: Run tests and regenerate** + +Run: `make test-unit name=TestGenerated` +Run the generators and inspect the diff against the committed `config-example.yml` and config reference. The diff should be limited to ordering and to keys that were conditionally omitted before. **If a `TMI_*` variable disappears from the reference, stop** — that silently narrows the env-var allowlist. + +- [ ] **Step 5: Lint and commit** + +```bash +make lint +git add cmd/genconfig cmd/genconfigdocs config-example.yml +git commit -m "refactor(config): generate config template and reference from the registry" +``` + +--- + +### Task 9: The transitional ratchet + +**Files:** +- Create: `internal/config/transitional_ratchet_test.go` + +**Interfaces:** +- Consumes: `AllSettingDefs()` (Task 1). + +A golden list of the operational keys that still have a config/env path. It can only shrink. This is what stops Phase A from quietly becoming permanent, and its emptiness is the Phase E completion gate. + +- [ ] **Step 1: Write the test** + +```go +package config + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" +) + +// transitionalKeys is the golden list of operational settings that still have +// a config-file or environment delivery path, pending Phase E of the config +// model redesign (docs/superpowers/specs/2026-08-22-config-model-redesign-design.md). +// +// This list may only SHRINK. Adding a key here means adding a new config/env +// path for a database-only setting, which is the thing the redesign exists to +// remove. When it reaches zero, Phase E is complete and goal 2 is enforced. +// +// Populate it in Step 2 from the test's own failure output. +var transitionalKeys = []string{ + // filled in from the first run +} + +func TestTransitionalKeys_MatchGoldenListExactly(t *testing.T) { + var actual []string + for _, d := range AllSettingDefs() { + if d.Transitional { + actual = append(actual, d.Key) + } + } + sort.Strings(actual) + + want := append([]string{}, transitionalKeys...) + sort.Strings(want) + + assert.Equal(t, want, actual, + "the transitional list may only shrink: removing a key means its config/env "+ + "path is gone (good); adding one means a new config/env path was introduced "+ + "for a database-only setting (not allowed)") +} + +func TestTransitionalKeys_AreAllOperational(t *testing.T) { + for _, d := range AllSettingDefs() { + if d.Transitional { + assert.Equal(t, CategoryOperational, d.Class.Category, + "%s is Transitional but not operational", d.Key) + } + } +} +``` + +- [ ] **Step 2: Run, then populate the golden list from the failure** + +Run: `make test-unit name=TestTransitionalKeys_MatchGoldenListExactly` +Expected: FAIL, printing the actual list. Copy those keys verbatim into `transitionalKeys`, one per line, sorted. + +- [ ] **Step 3: Re-run to verify it passes** + +Run: `make test-unit name=TestTransitionalKeys` +Expected: PASS. + +- [ ] **Step 4: Verify the ratchet bites in the addition direction** + +Temporarily set `Transitional: true` on one non-transitional operational def and confirm the test FAILS naming it. Revert. + +- [ ] **Step 5: Lint and commit** + +```bash +make lint +git add internal/config/transitional_ratchet_test.go +git commit -m "test(config): ratchet the list of operational settings still delivered by config/env" +``` + +--- + +### Task 10: Full verification and Oracle review + +**Files:** +- No source changes expected. Fix whatever the gates surface. + +- [ ] **Step 1: Run the full local gate** + +```bash +make lint +make build-server +make test-unit +``` +Expected: 0 lint issues; clean build; full unit suite green. + +- [ ] **Step 2: Run the integration suite** + +```bash +make test-integration +``` +Expected: 85 passed / 0 failed (the 1.8.22 baseline). + +If `TestIdentityLink` or `TestIdentityLink_SecondConfirmRejected` fail, check for foreign listeners on `:8080`/`:6379` before blaming the diff (#778) — but do not assume it. Prove it against a clean `main` worktree if in doubt. + +- [ ] **Step 3: Dispatch the Oracle review** + +`DefaultSystemSettings()` changing from a hand-written list to a registry projection changes seeding behaviour, which is DB-touching. Invoke the `oracle-db-admin` skill and dispatch the subagent with the full diff. + +Address every BLOCKING finding before proceeding; fold APPROVED WITH NOTES items in or file follow-ups. Two things to raise explicitly: +- The seed list gains two rows (`rate_limit.*` are already seeded today, so in fact it gains none — confirm that against the current row set rather than assuming). +- `SeedDefaults` skips existing rows, so a projection change does not rewrite existing databases. Confirm that is still true, and that the `origin` stamping from #794 is unaffected. + +- [ ] **Step 4: Refresh SEM markers** + +```bash +/sem-annotate --update internal/config/setting_def.go internal/config/setting_def_validation.go internal/config/setting_defs_bootstrap.go internal/config/setting_defs_operational.go internal/config/seed_projection.go internal/config/migratable_settings.go api/models/system_setting.go +``` + +Markers must follow the code commits — `sem blame` cannot anchor a file that is not yet in HEAD. + +- [ ] **Step 5: Commit and open the PR** + +```bash +make lint +git add -u +git commit -m "chore(config): refresh SEM markers for the registry consolidation" +git push -u origin dev/1.9.0/config-model-redesign +``` + +Open a PR titled `feat(config): make one registry the authoritative declaration of every setting` — `feat:` drives the minor bump to 1.9.0. Expect to approve the workflow runs by hand after the version-bump commit lands (#797), and expect "Version Check" to fail on the pre-bump run and pass on the next. + +--- + +## Out of scope for this plan + +These are spec'd but belong to later plans: + +- **Phase B** — the template tool: `!secret` references, `--effective` export, `--environment` guard, `--prune`, and #807's schema preflight. +- **Phases C/D** — per-environment cutover. An operational runbook, not code. +- **Phase E** — deleting the operational config/env paths, the #794 precedence machinery, `warnOnConfigDatabaseDivergence`, and `nonRoundTrippingKeys`; resolving the three derived aliases (`session.timeout_minutes`, `features.saml_enabled`, `auth.oauth_callback_url`). +- **The `auth.tmi_provider.*` sub-tree and the reserved `tmi` provider id** — these change auth behaviour and belong with Phase B, where the template becomes the production identity path. +- **`server.base_url` becoming required for non-dev builds** — a behaviour change, deferred to Phase E. +- **Surfacing `Mutability` through the admin API** — Task 4 *declares* hot vs restart-required per setting, which is the prerequisite. Reporting it on `GET /admin/settings/{key}` and returning the "takes effect on restart" signal from `PUT` is an API change requiring an OpenAPI spec edit and regeneration, so it lands with Phase B alongside #803's `origin` exposure — one OpenAPI regen instead of two, since `api/api.go` rebases badly. diff --git a/docs/superpowers/specs/2026-08-22-config-model-redesign-design.md b/docs/superpowers/specs/2026-08-22-config-model-redesign-design.md new file mode 100644 index 00000000..b2d2e9b5 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-config-model-redesign-design.md @@ -0,0 +1,446 @@ +# Config model redesign: bootstrap in files, everything else in the database + +**Date:** 2026-08-22 +**Status:** Approved (design) +**Supersedes parts of:** #415 (three-category model) +**Closes:** #793 (as obsolete) +**Depends on:** #807 (dbtool schema preflight) as a hard prerequisite for Phase C + +## Problem + +TMI's configuration has four layers (defaults → config file → environment → +database) and, for any given setting, up to four *declaration sites*: the +`Config` struct's yaml/env tags, `GetMigratableSettings()`, +`classification_registry.go`, and `DefaultSystemSettings()`. Nothing structural +keeps those four in agreement. + +Two live bugs are symptoms of the same defect: + +- **#793** — seven keys export from the database but are silently dropped on + import, because export walks database rows while import walks + `GetMigratableSettings()`. A customized value for any of them is + unrecoverable by #792's snapshot/restore. +- **The `rate_limit.*` 404** — `rate_limit.requests_per_minute` and + `rate_limit.requests_per_hour` are seeded into `system_settings` but have no + classification entry (neither exact nor prefix). Unclassified resolves to + `VisibilityInternal`, so `GET`/`DELETE /admin/settings/{key}` returns 404 for + keys the LIST endpoint displays. This is the same list/get inconsistency + commit `8f7b5125` fixed for five sibling keys and did not sweep up. It is live + in production today. + +The guardrail that should have caught both cannot: `ValidateClassifications` +takes a `[]MigratableSetting` and validates whatever list it is handed — in +practice `GetMigratableSettings()`, which by construction only contains keys +that already have a `Config` struct field. Keys seeded straight into the +database are invisible to it. + +Underneath that, the env-vs-database precedence question has now been answered +three times (#415, #767, #794) because two sources of truth for one value is a +contest that has no stable resolution. #794 converged the rules onto one table; +this design removes the contest. + +## Goals + +1. Config file and environment variables are available for **bootstrap settings + only**, and for **all** bootstrap settings. +2. Everything not needed for bootstrap is **database-only**. +3. A templating mechanism dumps current database settings to a file and imports + a template file into a database. +4. Per-environment templates exist and are used whenever a database is cleared + or an environment is deployed or rebuilt. +5. Precedence is simple and exists only for bootstrap: **env > file > compiled + default**. +6. Every setting has a classification, enforced by guardrails that make an + unclassified setting impossible rather than merely detectable. + +## Non-goals + +- Changing how workers receive projected shared config (the component-platform + projection from #415 is unaffected). +- Changing settings-at-rest encryption (#547). +- Adding any new `system_settings` column. The design deliberately avoids a + schema migration. + +--- + +## 1. The model + +### Two categories, each with exactly one delivery path + +**Bootstrap** — everything needed to start the process and reach a usable, +administrable service. Source: config file and environment only. Never stored in +the database; this is already true today (`cmd/server/startup_checks.go:143` +skips bootstrap keys because "there is no DB row to diverge from"). + +Precedence: **env > file > compiled default**. No origin tracking, no +explicit-vs-seeded distinction, no divergence warning. + +**Operational** — everything else. Source: the database only. No yaml path, no +env var, no `Config` struct field. Read through the settings service by key. + +### The identity floor + +The built-in `tmi` provider is bootstrap and lives in its own sub-tree, +deliberately **not** under `auth.oauth.providers.*`: + +``` +auth.tmi_provider.enabled bootstrap, bool, default false +auth.tmi_provider.callback_url bootstrap, string, optional +``` + +Keeping it out of `auth.oauth.providers.*` means that prefix denotes exactly one +thing — a real IdP, database-only — with no carve-out, which is what makes the +prefix classification honest. + +`auth.tmi_provider.callback_url` is optional and derives from `server.base_url` +when empty. It must exist as an explicit override because derivation is provably +insufficient: `deployments/k8s/dev/oauth-providers.env.example:48-57` documents +that k3s requires `https://tmi.efitz.net/api/oauth2/callback`, where the `/api` +prefix belongs to tmi-ux's ingress rather than to the server. No derivation from +`base_url` could produce it, and requesting the underived URL returns the SPA's +index.html instead of the callback handler. + +**Translation, not special-casing.** One adapter function converts the +`auth.tmi_provider.*` bootstrap config into the same provider struct every other +IdP produces. The OAuth core is unchanged and never learns that two delivery +paths exist. + +**`tmi` is a reserved provider id.** Writes to `auth.oauth.providers.tmi.*` are +rejected by the admin API with a 400 explaining that it is bootstrap-configured; +any such row already present is ignored at load with a warning. Without this, +two definitions can exist for one id and resolution order silently picks a +winner. + +**Production caveat, stated plainly.** In a production build the tmi provider +refuses the authorization-code/PKCE flow (`auth/test_provider.go:87-93`) and +supports only the Client Credentials Grant; CC-grant tokens are service-account +tokens, which are categorically denied on `/admin/*` (`api/auth_helpers.go:52`, +per #399). So the tmi provider is a genuine identity floor in dev/test builds +and a machine-auth switch in production. **Production's identity floor is the +template import**, which is why goal 3 must exist before goal 2 can be true. + +### `server.base_url` + +Bootstrap, and **required for any non-dev build**, validated at startup rather +than silently inferred. Today `Config.GetBaseURL()` +(`internal/config/config.go:1348-1369`) falls back to inferring from `Interface`, +`Port`, and `TLSEnabled` — the *bind* address. Behind the AWS ALB every +component of that inference is wrong (scheme `http` because TLS terminates at +the load balancer, host `0.0.0.0` because the server binds all interfaces, port +`8080` because that is the container port), yielding `http://0.0.0.0:8080` where +the truth is `https://api.tmi.dev`. + +The adjacent `GetCookieDomain()` carries the scar from the same class of bug: +deriving the cookie Domain from the bind address produced `Domain=0.0.0.0`, +which browsers rejected, silently discarding auth cookies (#497). + +The inference path survives only in dev/test builds, where the server really is +the public endpoint, and fails closed elsewhere. + +**Inference from `X-Forwarded-Host`/`X-Forwarded-Proto` is explicitly +rejected.** For a value that determines where OAuth redirects land, deriving it +from a client-suppliable header turns a spoofed header into a callback hijack. + +### Restart semantics + +The registry's existing `Mutability` field is promoted from decorative to +load-bearing. Every operational setting declares `Hot` or `RestartRequired`. The +admin API reports it, and a `PUT` to a restart-required setting returns 200 with +an explicit "takes effect on restart" signal rather than appearing to work. + +--- + +## 2. One registry, one declaration per setting + +Each setting is declared exactly once: + +```go +{ + Key: "auth.jwt.expiration_seconds", + Category: Bootstrap, // or Operational + Type: Int, + Default: 3600, + Visibility: AdminOnly, + Secret: false, + Mutability: RestartRequired, // operational only + Consumers: []Consumer{Monolith}, + Description: "...", + // bootstrap only: + YAMLPath: "auth.jwt.expiration_seconds", + EnvVar: "TMI_JWT_EXPIRATION_SECONDS", +} +``` + +Category determines which fields are legal, and validation enforces it: + +- **Bootstrap** entries must have both `YAMLPath` and `EnvVar`, and no + `Delivery`. This is goal 1's "always available for ALL bootstrap settings", + enforced rather than hoped for. It matters because the key→env-var mapping is + irregular and cannot be derived. +- **Operational** entries must have neither `YAMLPath` nor `EnvVar`, and must + have a `Default` and a `Mutability`. + +### Derived consumers + +Three things derive from the registry instead of duplicating it: + +- `DefaultSystemSettings()` becomes a projection of the operational entries. The + database seed list stops being a hand-kept parallel list, which is what + stranded `rate_limit.*` outside classification. +- `genconfig` and `genconfigdocs` re-point at the registry as their source (they + generate `config-example.yml` and the config reference from the `Config` + struct today). +- The `Config` struct keeps typed bootstrap fields for startup code, with a test + asserting struct fields and bootstrap registry entries are in exact bijection. + +`ValidateClassifications` is promoted from "validates the list you hand it" to +"validates the registry, which is the whole set by construction." + +--- + +## 3. The template mechanism + +### The artifact + +One YAML file per environment. Every operational setting, with secret-classified +values represented as **references**, never material: + +```yaml +# tmi-config-template v1 +environment: aws-public +settings: + auth.oauth.providers.google.client_id: "1234....apps.googleusercontent.com" + auth.oauth.providers.google.client_secret: !secret aws-sm://tmi/aws-public/oauth/google/client_secret + auth.oauth.callback_url: "https://api.tmi.dev/oauth2/callback" + administrators: ["google:...:admin@example.com"] +``` + +`!secret` resolves at **import** time through the existing +`internal/secrets.Provider` interface, which already implements AWS Secrets +Manager, OCI Vault, and env backends and is already constructed by dbtool. The +server never learns references exist; it reads resolved, encrypted-at-rest +database rows exactly as it does now. + +### Export never emits secret material + +Not even behind a flag — that flag is how plaintext ends up on a laptop. For +secret-classified keys, export emits: + +- the reference from a prior template when one is supplied via + `--template `, or +- a `TODO` placeholder that import **refuses** to resolve. + +Fail-closed: a half-authored template errors loudly rather than quietly +installing an empty client secret. + +**Rejected alternative:** storing the reference in a new `system_settings` +column so export could round-trip it unaided. It buys the same thing at the cost +of a schema migration and an Oracle migration review, when the file can carry +it. + +### Export modes + +- `--from-db` — current rows. The steady-state mode. +- `--effective` — rows *plus* currently env-resolved operational values. This is + the cutover bridge: it captures AWS's provider config from the running system + instead of requiring it to be hand-typed. **Temporary by construction** — once + no operational setting has an env path it degenerates into `--from-db`, and it + is deleted in Phase E. + +### Import semantics + +Convergent and idempotent. Default is upsert-what-is-in-the-template. `--prune` +(off by default) additionally deletes operational rows the template does not +mention, which is what makes a template a complete environment definition rather +than a patch. `--prune` never touches bootstrap. + +Import **requires** an explicit `--environment ` and refuses when the +file's declared `environment:` does not match. Importing `aws-public.yaml` +against a dev database — or the reverse — costs an error message, not an outage. + +Every write lands in the audit trail attributed to the template import, so +`system_audit_entries` still answers "who set this". + +### `origin` is repurposed, not deleted + +#794's `system_settings.origin` column was built to referee env-vs-database +precedence, a contest this design removes. It survives with a better job: export +takes only rows marked **explicit**, skipping seeded defaults. A template +therefore captures *operator intent* rather than a snapshot of whatever the +defaults happened to be — so when a default changes in a later release, restored +environments pick up the new default instead of being frozen at the old one by +their own template. + +--- + +## 4. Per-environment templates and deploy integration + +### Location, split by ownership rather than by secrecy + +Because templates carry no secret material, they can be tracked. The split is +whether the environment belongs to the project or to an individual. + +**Tracked** — environments the project deploys: + +``` +deployments/config-templates/aws-public.yaml # production +deployments/config-templates/oci.yaml # when it exists +``` + +**Untracked** — anything belonging to one developer's machine or lab, free-form +names: + +``` +.local/config-templates/k3s-rp.yaml # a personal dev cluster +.local/config-templates/docker-desktop.yaml # per-developer local +``` + +Resolution searches `.local/config-templates/` **first**, then the tracked +directory, so a developer can shadow a tracked template locally without editing +a tracked file. That is also how a production config change gets tested safely. + +### Deploy integration + +Both hooks already exist and are re-pointed rather than invented: + +- `deploy-aws.sh` has a `--config-export FILE` argument that already runs a + dbtool import as Phase 7. It targets the environment template and becomes + non-optional for a fresh environment. +- `scripts/devenv.py` already snapshots before every teardown and restores after + `start()` (#792). + +### Ordering + +The server owns schema migration, so: + +``` +apply workloads + → server boots (migrates schema; no providers yet, not yet usable) + → import template + → restart (or wait out the settings cache) + → verify identity +``` + +The "not yet usable" window is inherent to database-only identity and is the +price of goal 2. + +This makes **#807 a hard prerequisite, not a nicety**: dbtool must preflight the +target schema version and refuse to import against a database the current server +has not migrated. Once template import is the only path into a new environment, +a silent failure there produces an environment nobody can log in to. + +--- + +## 5. What gets deleted + +- ~48 operational fields from the `Config` struct, with their yaml paths and env + tags. +- The env-vs-database precedence machinery from #794. `GetResolvedString` + collapses to a plain database read for operational and a plain config read for + bootstrap. The dual-accessor hazard that PR eliminated stops being *possible* + rather than being refereed. +- `warnOnConfigDatabaseDivergence` and its startup warning — there is no longer + a contest to warn about. +- `nonRoundTrippingKeys` in `cmd/dbtool/config_export.go`. + +### The three derived aliases, resolved + +`nonRoundTrippingKeys` names three keys as "derived display values", each +shadowing a real source. Under one registry a key is either a real setting or it +is not; a third state called "display alias that silently does not import" is +precisely what produced #793. Resolutions: + +- **`session.timeout_minutes`** — delete as a setting. It is a computed view of + `auth.jwt.expiration_seconds`. If tmi-ux needs it on `/config`, `/config` + computes it. +- **`features.saml_enabled`** — promote to a real operational setting with + `Mutability: RestartRequired`, gating SAML manager construction in place of + `TMI_SAML_ENABLED`. This resolves the #794 asymmetry (where the env var gated + construction regardless of the database row) rather than documenting it. + Per-provider `enabled` flags remain separate rows. +- **`auth.oauth_callback_url`** — two names for one value. Keep + `auth.oauth.callback_url`, consistent with the `auth.oauth.providers.*` + sub-tree; migrate existing rows and delete the alias. + +--- + +## 6. Cutover + +| Phase | What | Risk | +| --- | --- | --- | +| A | Build the registry + total-coverage guardrail. No behavior change. | None — pure refactor, tests prove equivalence | +| B | Build the template tool: references, `--effective`, `--environment` guard. #807 preflight lands here. | None — new tool, nothing depends on it yet | +| C | Per environment: export `--effective` → import → restart → verify identity, **env vars still present** | Reversible; env remains the live source until D | +| D | Per environment: remove the operational env vars | The real cutover; one environment at a time, k3s before AWS | +| E | Delete operational config/env code paths, `--effective`, and the #794 precedence machinery | None — dead by then | + +A and B are safe to land in any release. C and D are per-environment and +independently revertible. E happens only after every environment has completed +D, which the Phase-E guardrail test asserts directly. + +--- + +## 7. Testing + +### Registry guardrails (these are goal 6, as build-failing tests) + +- **Total coverage:** every key reachable by the server — `Config` struct field, + registry entry, or seeded row — resolves to a registry entry with a non-zero + `Category`. This is the test that would have caught `rate_limit.*`. +- **Category legality:** bootstrap entries have both `YAMLPath` and `EnvVar` and + no `Delivery`; operational entries have neither, plus a `Default` and a + `Mutability`. +- **Bijection:** bootstrap registry entries ↔ `Config` struct fields, exactly. +- **Goal 2's enforcement:** no operational key has an `env` or `yaml` tag + anywhere in the `Config` struct. This doubles as the Phase-E completion gate — + when it passes, the cutover is provably done. +- `tmi` is reserved: writes to `auth.oauth.providers.tmi.*` are rejected. + +### Template round-trip + +- export → import → export is byte-identical. +- **Fail-closed secrecy test:** seed known secret values, export, assert the file + contains none of them. This is the test that stops a future refactor from + quietly reintroducing plaintext export. +- A `TODO` placeholder refuses to import rather than installing an empty secret. +- `--environment` mismatch refuses. +- `--prune` deletes only unmentioned operational rows and never touches + bootstrap. +- CI check: any *tracked* template whose secret-classified key holds anything + but a `!secret` reference fails the build. + +### Behavior + +- Bootstrap precedence table test: env > file > default, including the + empty-string-vs-unset distinction that has bitten this repo on Oracle. +- Restart-required settings return the "takes effect on restart" signal from the + admin API rather than appearing to apply. + +### Integration — the test that actually matters + +Fresh database + template import + restart yields a **working login**. Every +other test here can pass while the system is unusable. This is what proves +Phases C and D are safe, and it runs against k3s before AWS is touched. + +### Oracle + +`oracle-db-admin` review is mandatory before completion. Making +`DefaultSystemSettings()` a projection changes seeding behavior, and the +explicit-only export changes which rows are read — both are DB-touching. The +design adds **no new column**, which keeps this a behavior review rather than a +migration review. + +--- + +## 8. Issue disposition + +- **#793** — close as obsolete. The export/import registry mismatch cannot exist + once `DefaultSystemSettings()` is a projection of the one registry. +- **`rate_limit.*` 404** — file separately and fix independently. It is live in + production now and this work spans several releases. +- **#807** — promoted from follow-up to a hard prerequisite for Phase C. +- **#803** (expose `origin` via the admin settings API) — still wanted; `origin` + survives this design with a new purpose. +- **#805** (`ReEncryptAll` stamps `modified_by`, blunting it as an intent + signal) — becomes more relevant, since explicit-vs-seeded now decides what a + template captures. diff --git a/internal/config/classification_registry.go b/internal/config/classification_registry.go index 59d0aa20..cbd2cf62 100644 --- a/internal/config/classification_registry.go +++ b/internal/config/classification_registry.go @@ -102,6 +102,7 @@ var exactClassifications = map[string]ConfigClass{ "server.idle_timeout": bootstrapClass(false, VisibilityInternal, false), "server.base_url": bootstrapClass(false, VisibilityPublic, false), "server.cors.allowed_origins": bootstrapClass(false, VisibilityInternal, false), + "server.trusted_proxies": bootstrapClass(false, VisibilityInternal, false), // Operational server knobs — rate-limiting and optimistic-locking (#426) "server.disable_rate_limiting": operationalClass(VisibilityAdminOnly, false), "server.ratelimit_public_rpm": operationalClass(VisibilityAdminOnly, false), @@ -196,6 +197,19 @@ var exactClassifications = map[string]ConfigClass{ "upload.max_file_size_mb": operationalClass(VisibilityPublic, false, ConsumerMonolith, ConsumerTMIUX), "ui.default_theme": operationalClass(VisibilityPublic, false, ConsumerMonolith, ConsumerTMIUX), + // --- Operational: seeded-but-unread rate limit knobs (#809) --- + // Same DB-only shape as the client-config knobs above — no Config struct + // field, seeded directly by models.DefaultSystemSettings — but + // admin-only rather than public: unlike the five keys above, these are + // not consumed by tmi-ux via /config. Nothing reads either key at use + // time; real rate limiting runs off server.disable_rate_limiting / + // server.ratelimit_public_rpm instead. Without this classification they + // default to VisibilityInternal, which is exactly what made + // GET/DELETE /admin/settings/{key} 404 on both even though the LIST + // endpoint showed them. + "rate_limit.requests_per_minute": operationalClass(VisibilityAdminOnly, false), + "rate_limit.requests_per_hour": operationalClass(VisibilityAdminOnly, false), + // --- Bootstrap: logging & observability --- "logging.level": bootstrapClass(false, VisibilityInternal, false), "logging.is_dev": bootstrapClass(false, VisibilityInternal, false), diff --git a/internal/config/example_gen.go b/internal/config/example_gen.go index c6430378..34e7b1c3 100644 --- a/internal/config/example_gen.go +++ b/internal/config/example_gen.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "fmt" "sort" "strconv" @@ -56,7 +57,7 @@ func GenerateExampleConfig() ([]byte, error) { // coerceSettingValue converts a MigratableSetting's string Value to the // appropriate Go type based on the Type field, so that yaml.Marshal outputs // clean YAML (integers without quotes, booleans as true/false, etc.). -// SEM@a60b4f430769f6d36f0e3753a429ea699ba8b1a0: convert a migratable setting's string value to its typed Go representation for YAML output (pure) +// SEM@e6cee63c3a07d38f471e0ebfb81722849f36085e: convert a migratable setting's string value to its typed Go representation for YAML output (pure) func coerceSettingValue(s MigratableSetting) any { switch s.Type { case "int": @@ -71,11 +72,43 @@ func coerceSettingValue(s MigratableSetting) any { if f, err := strconv.ParseFloat(s.Value, 64); err == nil { return f } + case "json": + return coerceJSONSettingValue(s.Value) } - // Default: return as string (handles "string", "json", duration strings, etc.) + // Default: return as string (handles "string", duration strings, etc.) return s.Value } +// coerceJSONSettingValue decodes a "json"-typed setting's already-marshaled +// string into a native Go value, so yaml.Marshal renders it as a real YAML +// list/mapping rather than dumping the JSON text as a quoted string. +// +// A nil Go slice (e.g. an unset []string field) marshals to the JSON string +// "null"; without this decoding step that string flows through unchanged and +// yaml.Marshal writes the literal YAML string `"null"`, which is not valid +// YAML for a list-typed key. An explicitly-empty JSON array ("[]") has the +// same problem in miniature: it would otherwise round-trip as the string +// "[]" rather than an empty list. Both cases are normalized to an empty Go +// slice here, which yaml.Marshal renders as `[]` — matching the convention +// this file already uses for empty collections (server.cors.allowed_origins +// is omitted outright via OmitWhenEmpty when empty; a setting that is +// deliberately not OmitWhenEmpty, like server.trusted_proxies, still needs a +// valid, honest empty-list rendering when its default is unset). +// SEM@e6cee63c3a07d38f471e0ebfb81722849f36085e: decode a JSON-typed setting's string value into a native Go value for YAML (pure) +func coerceJSONSettingValue(value string) any { + if value == "" || value == "null" || value == "[]" { + return []any{} + } + var decoded any + if err := json.Unmarshal([]byte(value), &decoded); err == nil { + return decoded + } + // Malformed JSON should not happen (the value came from json.Marshal in + // the setting's own Get accessor); fall back to the raw string rather + // than panicking or silently dropping the key. + return value +} + // setNested writes value into a nested map following the dotted key path. // SEM@a60b4f430769f6d36f0e3753a429ea699ba8b1a0: write a value into a nested map following a dotted key path (mutates shared state) func setNested(root map[string]any, path []string, value any) { diff --git a/internal/config/example_gen_test.go b/internal/config/example_gen_test.go index b3ed5c18..86840b43 100644 --- a/internal/config/example_gen_test.go +++ b/internal/config/example_gen_test.go @@ -4,6 +4,8 @@ import ( "os" "strings" "testing" + + "gopkg.in/yaml.v3" ) func TestGenerateExampleConfig_ContainsBootstrapKeys(t *testing.T) { @@ -29,6 +31,111 @@ func TestGenerateExampleConfig_OmitsOperationalKeys(t *testing.T) { } } +// TestCoerceSettingValue_NilSliceJSON pins the fix for a dormant bug: a +// JSON-typed setting whose Value is the marshaled form of a nil Go slice +// ("null") must coerce to an empty YAML list, not the literal string +// "null" (which is invalid YAML for a list-typed config key). +func TestCoerceSettingValue_NilSliceJSON(t *testing.T) { + s := MigratableSetting{Type: "json", Value: "null"} + got := coerceSettingValue(s) + + out, err := yaml.Marshal(map[string]any{"trusted_proxies": got}) + if err != nil { + t.Fatalf("yaml.Marshal: %v", err) + } + if strings.TrimSpace(string(out)) != "trusted_proxies: []" { + t.Errorf("nil-slice JSON value coerced to %q, want an empty YAML list; got YAML %q", got, out) + } +} + +// TestCoerceSettingValue_EmptyArrayJSON pins the companion case: an +// explicitly-empty JSON array ("[]") must also coerce to an empty YAML list +// rather than the quoted string "[]". +func TestCoerceSettingValue_EmptyArrayJSON(t *testing.T) { + s := MigratableSetting{Type: "json", Value: "[]"} + got := coerceSettingValue(s) + + out, err := yaml.Marshal(map[string]any{"trusted_proxies": got}) + if err != nil { + t.Fatalf("yaml.Marshal: %v", err) + } + if strings.TrimSpace(string(out)) != "trusted_proxies: []" { + t.Errorf("empty-array JSON value coerced to %q, want an empty YAML list; got YAML %q", got, out) + } +} + +// TestCoerceSettingValue_PopulatedJSONArray confirms a populated JSON array +// decodes into a real YAML list, not a quoted JSON string. +func TestCoerceSettingValue_PopulatedJSONArray(t *testing.T) { + s := MigratableSetting{Type: "json", Value: `["10.0.0.0/8","172.16.0.0/12"]`} + got := coerceSettingValue(s) + + out, err := yaml.Marshal(map[string]any{"trusted_proxies": got}) + if err != nil { + t.Fatalf("yaml.Marshal: %v", err) + } + want := "trusted_proxies:\n - 10.0.0.0/8\n - 172.16.0.0/12" + if strings.TrimSpace(string(out)) != want { + t.Errorf("populated JSON array coerced to YAML %q, want %q", out, want) + } +} + +// TestGenerateExampleConfig_OmitsDatabaseOnlySettings guards against a +// database-only setting appearing in the config-file template. A setting +// that is CategoryOperational and not Transitional has no config-file +// representation by construction (Phase E's design: it lives only in the +// database), so offering one in the sample file would invite an operator to +// set something the server silently ignores. +// +// The generated document is nested YAML (setNested + yaml.Marshal), so a +// dotted key like "timmy.enabled" never appears as a literal substring even +// when the setting IS emitted — it renders as "timmy:\n enabled: ...". +// A substring check on the raw text can never see the thing it is supposed +// to guard against, so this parses the generated YAML back into a tree and +// checks dotted paths reconstructed from that tree instead. +func TestGenerateExampleConfig_OmitsDatabaseOnlySettings(t *testing.T) { + out, err := GenerateExampleConfig() + if err != nil { + t.Fatalf("GenerateExampleConfig: %v", err) + } + present := yamlDottedPaths(t, out) + for _, d := range AllSettingDefs() { + if d.Class.Category == CategoryOperational && !d.Transitional { + if present[d.Key] { + t.Errorf("%s is database-only and must not appear in the config template", d.Key) + } + } + } +} + +// yamlDottedPaths parses generated YAML and returns the set of every dotted +// path present in it — both intermediate mapping keys and leaf scalar keys +// — so a test can check "is this dotted setting key present" against the +// document's real structure rather than its raw text. +func yamlDottedPaths(t *testing.T, doc []byte) map[string]bool { + t.Helper() + var root map[string]any + if err := yaml.Unmarshal(doc, &root); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + paths := map[string]bool{} + var walk func(prefix string, node map[string]any) + walk = func(prefix string, node map[string]any) { + for k, v := range node { + path := k + if prefix != "" { + path = prefix + "." + k + } + paths[path] = true + if child, ok := v.(map[string]any); ok { + walk(path, child) + } + } + } + walk("", root) + return paths +} + func TestConfigExampleFile_MatchesRegistry(t *testing.T) { generated, err := GenerateExampleConfig() if err != nil { diff --git a/internal/config/migratable_settings.go b/internal/config/migratable_settings.go index 4ddfcdd5..89818942 100644 --- a/internal/config/migratable_settings.go +++ b/internal/config/migratable_settings.go @@ -60,34 +60,108 @@ func settingSource(envVar string) string { return "config" } -// GetMigratableSettings returns all settings from the config formatted for database storage. -// Secret fields are included with Secret=true so the API layer can mask their values. -// SEM@10b74985ed52c143cb0fb6e853b2d5f106de198f: list all config settings eligible for database migration with classification applied (pure) +// isOmittableEmptyValue reports whether value is the "unset" representation +// for a setting of the given canonical Type, for the purposes of +// OmitWhenEmpty (see its doc comment on SettingDef). The zero-value string +// differs by type: "" for string/bool/float, "0" for int (every current +// OmitWhenEmpty int-typed key used ">0" as its pre-registry emission guard, +// so "0" is the correct empty sentinel for them — a future int OmitWhenEmpty +// setting where 0 is a meaningful non-empty value would need its own +// handling here), and "[]" or "null" for json (json.Marshal renders a nil +// slice as "null" and an explicitly empty one as "[]"). +// SEM@71b4a22251f6acd8a9fb37257d5938c174392b09: determine whether a setting's value is its type's empty/unset sentinel (pure) +func isOmittableEmptyValue(settingType, value string) bool { + switch settingType { + case "int": + return value == "0" + case "json": + return value == "[]" || value == "null" + default: + return value == "" + } +} + +// GetMigratableSettings returns every setting that has a config-file or +// environment delivery path, with its current value read from this Config. +// +// This projects the registry (setting_defs_*.go) rather than maintaining a +// parallel list. A def with OmitWhenEmpty is left out entirely when its +// value is empty, reproducing the pre-registry builders' conditional +// emission (see OmitWhenEmpty's doc comment on SettingDef for why this +// matters beyond cosmetics). server.tls_cert_file and server.tls_key_file +// are special-cased instead of using OmitWhenEmpty: their old emission guard +// was server.tls_enabled, a different field than the one being emitted, and +// OmitWhenEmpty can only test a def's own Get() output. +// +// auth.oauth.providers.*, auth.saml.providers.* and content_oauth.providers.* +// are generated per configured provider rather than declared statically — +// dynamic cardinality means no fixed dotted key — and are appended after the +// registry projection. +// +// A def whose Key appears in ExpectedMigratableKeysSkipped() is never +// emitted, regardless of OmitWhenEmpty: that list's predicate is exactly +// "intentionally not a migratable setting" (see its doc comment), and two of +// its entries — content_token_encryption_key, database.oracle_wallet_location +// — now have a SettingDef with a real Get accessor. Matching on Key rather +// than YAMLPath matters: the list's other three entries are rename cases +// keyed by the pre-refactor STRUCT path (e.g. "auth.oauth.callback_url"), +// while the corresponding defs' Key is the renamed migratable-setting key +// (e.g. "auth.oauth_callback_url") — matching Key against that map leaves +// those three correctly emitted under their renamed key and skips only the +// two bootstrap-by-construction keys. +// +// Class is assigned via classificationFor(key) in the final pass below, for +// every emitted setting — defs-projected and generated provider keys alike — +// exactly as the pre-registry implementation did. It is NOT taken from +// SettingDef.Class directly, even though the two agree on +// Category/Visibility/Secret for every currently-emitted key (Task 5 built +// SettingDef.Class as classificationFor(key) with Mutability overridden via +// withMutability(...) on many operational defs). Consuming SettingDef.Class +// here would flow those Mutability overrides into GetMigratableSettings' +// observable output for the first time — a real behavior change (dozens of +// keys' hot/static column in config-reference.md) that Ruling 15 explicitly +// deferred to Phase E ("making classificationFor() consult the registry... +// belongs in Phase E"). +// SEM@e6cee63c3a07d38f471e0ebfb81722849f36085e: list all config settings eligible for database migration with classification applied (pure) func (c *Config) GetMigratableSettings() []MigratableSetting { - settings := []MigratableSetting{} + defs := AllSettingDefs() + settings := make([]MigratableSetting, 0, len(defs)) + skip := ExpectedMigratableKeysSkipped() + + for _, d := range defs { + if d.Get == nil { + continue // database-only: no config path to read from + } + if _, ok := skip[d.Key]; ok { + continue // deliberately excluded — see ExpectedMigratableKeysSkipped + } + if (d.Key == "server.tls_cert_file" || d.Key == "server.tls_key_file") && !c.Server.TLSEnabled { + continue + } + value := d.Get(c) + if d.OmitWhenEmpty && isOmittableEmptyValue(d.Type, value) { + continue + } + settings = append(settings, MigratableSetting{ + Key: d.Key, + Value: value, + Type: d.Type, + Description: d.Description, + Source: settingSource(d.EnvVar), + EnvVar: d.EnvVar, + }) + } - settings = append(settings, c.getMigratableServerSettings()...) - settings = append(settings, c.getMigratableDatabaseSettings()...) - settings = append(settings, c.getMigratableAuthSettings()...) - settings = append(settings, c.getMigratableFeatureFlags()...) settings = append(settings, c.getMigratableOAuthSettings()...) settings = append(settings, c.getMigratableSAMLSettings()...) - settings = append(settings, c.getMigratableRuntimeSettings()...) - settings = append(settings, c.getMigratableLoggingSettings()...) - settings = append(settings, c.getMigratableSecretsSettings()...) - settings = append(settings, c.getMigratableAdministratorsSettings()...) - settings = append(settings, c.getMigratableTimmySettings()...) - settings = append(settings, c.getMigratableObservabilitySettings()...) - settings = append(settings, c.getMigratableSSRFSettings()...) - settings = append(settings, c.getMigratableWebhooksSettings()...) - settings = append(settings, c.getMigratableContentExtractorsSettings()...) settings = append(settings, c.getMigratableContentOAuthSettings()...) - settings = append(settings, c.getMigratableContentSourcesSettings()...) - settings = append(settings, c.getMigratableAlertingSettings()...) for i := range settings { settings[i].Class = classificationFor(settings[i].Key) // Keep the legacy per-setting Secret flag and Class.Secret consistent. + // One direction only: see IsSecret's doc comment for why clearing + // Secret here for a false Class.Secret would be wrong for the + // provider subtrees (auth.oauth.providers.*.client_secret etc.). if settings[i].Class.Secret { settings[i].Secret = true } @@ -103,153 +177,15 @@ func (c *Config) GetMigratableSettings() []MigratableSetting { return settings } -// getMigratableServerSettings returns server configuration settings -// SEM@2efe458db50a86b8f77bc2b6f5938a5d15cc4315: list server-section settings eligible for database migration (pure) -func (c *Config) getMigratableServerSettings() []MigratableSetting { - settings := []MigratableSetting{ - {Key: "server.port", Value: c.Server.Port, Type: "string", Description: "HTTP server port", Source: settingSource("TMI_SERVER_PORT"), EnvVar: "TMI_SERVER_PORT"}, - {Key: "server.interface", Value: c.Server.Interface, Type: "string", Description: "Network interface to bind to", Source: settingSource("TMI_SERVER_INTERFACE"), EnvVar: "TMI_SERVER_INTERFACE"}, - {Key: "server.tls_enabled", Value: strconv.FormatBool(c.Server.TLSEnabled), Type: "bool", Description: "TLS enabled", Source: settingSource("TMI_SERVER_TLS_ENABLED"), EnvVar: "TMI_SERVER_TLS_ENABLED"}, - {Key: "server.tls_subject_name", Value: c.Server.TLSSubjectName, Type: "string", Description: "TLS certificate subject name", Source: settingSource("TMI_SERVER_TLS_SUBJECT_NAME"), EnvVar: "TMI_SERVER_TLS_SUBJECT_NAME"}, - {Key: "server.http_to_https_redirect", Value: strconv.FormatBool(c.Server.HTTPToHTTPSRedirect), Type: "bool", Description: "HTTP to HTTPS redirect", Source: settingSource("TMI_SERVER_HTTP_TO_HTTPS_REDIRECT"), EnvVar: "TMI_SERVER_HTTP_TO_HTTPS_REDIRECT"}, - {Key: "server.read_timeout", Value: c.Server.ReadTimeout.String(), Type: "string", Description: "HTTP read timeout", Source: settingSource("TMI_SERVER_READ_TIMEOUT"), EnvVar: "TMI_SERVER_READ_TIMEOUT"}, - {Key: "server.write_timeout", Value: c.Server.WriteTimeout.String(), Type: "string", Description: "HTTP write timeout", Source: settingSource("TMI_SERVER_WRITE_TIMEOUT"), EnvVar: "TMI_SERVER_WRITE_TIMEOUT"}, - {Key: "server.idle_timeout", Value: c.Server.IdleTimeout.String(), Type: "string", Description: "HTTP idle timeout", Source: settingSource("TMI_SERVER_IDLE_TIMEOUT"), EnvVar: "TMI_SERVER_IDLE_TIMEOUT"}, - // Rate-limiting and optimistic-locking knobs (#426) - {Key: "server.disable_rate_limiting", Value: strconv.FormatBool(c.Server.DisableRateLimiting), Type: "bool", Description: "Disable all rate limiting (dev/test only)", Source: settingSource("TMI_DISABLE_RATE_LIMITING"), EnvVar: "TMI_DISABLE_RATE_LIMITING"}, - {Key: "server.ratelimit_public_rpm", Value: strconv.Itoa(c.Server.RateLimitPublicRPM), Type: "int", Description: "Requests per minute per IP for public endpoints", Source: settingSource("TMI_RATELIMIT_PUBLIC_RPM"), EnvVar: "TMI_RATELIMIT_PUBLIC_RPM"}, - {Key: "server.require_if_match", Value: strconv.FormatBool(c.Server.RequireIfMatch), Type: "bool", Description: "Return 428 when If-Match header is missing on PUT/PATCH", Source: settingSource("TMI_REQUIRE_IF_MATCH"), EnvVar: "TMI_REQUIRE_IF_MATCH"}, - } - if c.Server.BaseURL != "" { - settings = append(settings, MigratableSetting{Key: "server.base_url", Value: c.Server.BaseURL, Type: "string", Description: "Public base URL for callbacks", Source: settingSource("TMI_SERVER_BASE_URL"), EnvVar: "TMI_SERVER_BASE_URL"}) - } - if c.Server.TLSEnabled { - settings = append(settings, - MigratableSetting{Key: "server.tls_cert_file", Value: c.Server.TLSCertFile, Type: "string", Description: "TLS certificate file path", Source: settingSource("TMI_SERVER_TLS_CERT_FILE"), EnvVar: "TMI_SERVER_TLS_CERT_FILE"}, - MigratableSetting{Key: "server.tls_key_file", Value: c.Server.TLSKeyFile, Type: "string", Description: "TLS key file path", Source: settingSource("TMI_SERVER_TLS_KEY_FILE"), EnvVar: "TMI_SERVER_TLS_KEY_FILE"}, - ) - } - if len(c.Server.CORS.AllowedOrigins) > 0 { - originsJSON, _ := json.Marshal(c.Server.CORS.AllowedOrigins) - settings = append(settings, MigratableSetting{Key: "server.cors.allowed_origins", Value: string(originsJSON), Type: "json", Description: "CORS allowed origins", Source: settingSource("TMI_CORS_ALLOWED_ORIGINS"), EnvVar: "TMI_CORS_ALLOWED_ORIGINS"}) - } - return settings -} - -// getMigratableAuthSettings returns authentication configuration settings -// SEM@2efe458db50a86b8f77bc2b6f5938a5d15cc4315: list auth-section settings including JWT and cookie fields eligible for database migration (pure) -func (c *Config) getMigratableAuthSettings() []MigratableSetting { - settings := []MigratableSetting{ - {Key: "auth.build_mode", Value: c.Auth.BuildMode, Type: "string", Description: "Build mode (dev, test, production)", Source: settingSource("TMI_BUILD_MODE"), EnvVar: "TMI_BUILD_MODE"}, - {Key: "auth.auto_promote_first_user", Value: strconv.FormatBool(c.Auth.AutoPromoteFirstUser), Type: "bool", Description: "Auto-promote first user to admin", Source: settingSource("TMI_AUTH_AUTO_PROMOTE_FIRST_USER"), EnvVar: "TMI_AUTH_AUTO_PROMOTE_FIRST_USER"}, - {Key: "auth.everyone_is_a_reviewer", Value: strconv.FormatBool(c.Auth.EveryoneIsAReviewer), Type: "bool", Description: "Auto-add all users to Security Reviewers group", Source: settingSource("TMI_AUTH_EVERYONE_IS_A_REVIEWER"), EnvVar: "TMI_AUTH_EVERYONE_IS_A_REVIEWER"}, - } - // JWT settings - settings = append(settings, - MigratableSetting{Key: "auth.jwt.secret", Value: c.Auth.JWT.Secret, Type: "string", Description: "JWT signing secret", Source: settingSource("TMI_JWT_SECRET"), Secret: true, EnvVar: "TMI_JWT_SECRET"}, - MigratableSetting{Key: "auth.jwt.expiration_seconds", Value: strconv.Itoa(c.Auth.JWT.ExpirationSeconds), Type: "int", Description: "JWT token expiration in seconds", Source: settingSource("TMI_JWT_EXPIRATION_SECONDS"), EnvVar: "TMI_JWT_EXPIRATION_SECONDS"}, - MigratableSetting{Key: "auth.jwt.signing_method", Value: c.Auth.JWT.SigningMethod, Type: "string", Description: "JWT signing method", Source: settingSource("TMI_JWT_SIGNING_METHOD"), EnvVar: "TMI_JWT_SIGNING_METHOD"}, - MigratableSetting{Key: "auth.jwt.refresh_token_days", Value: strconv.Itoa(c.Auth.JWT.RefreshTokenDays), Type: "int", Description: "Refresh token TTL in days", Source: settingSource("TMI_REFRESH_TOKEN_DAYS"), EnvVar: "TMI_REFRESH_TOKEN_DAYS"}, - MigratableSetting{Key: "auth.jwt.session_lifetime_days", Value: strconv.Itoa(c.Auth.JWT.SessionLifetimeDays), Type: "int", Description: "Absolute session lifetime in days", Source: settingSource("TMI_SESSION_LIFETIME_DAYS"), EnvVar: "TMI_SESSION_LIFETIME_DAYS"}, - MigratableSetting{Key: "auth.step_up_window_seconds", Value: strconv.Itoa(c.Auth.StepUpWindowSeconds), Type: "int", Description: "Step-up auth_time freshness window in seconds for /admin/* writes (#355); minimum 60", Source: settingSource("TMI_AUTH_STEP_UP_WINDOW_SECONDS"), EnvVar: "TMI_AUTH_STEP_UP_WINDOW_SECONDS"}, - ) - // Cookie settings - settings = append(settings, - MigratableSetting{Key: "auth.cookie.enabled", Value: strconv.FormatBool(c.Auth.Cookie.Enabled), Type: "bool", Description: "HttpOnly cookie-based auth enabled", Source: settingSource("TMI_COOKIE_ENABLED"), EnvVar: "TMI_COOKIE_ENABLED"}, - MigratableSetting{Key: "auth.cookie.domain", Value: c.Auth.Cookie.Domain, Type: "string", Description: "Cookie domain", Source: settingSource("TMI_COOKIE_DOMAIN"), EnvVar: "TMI_COOKIE_DOMAIN"}, - MigratableSetting{Key: "auth.cookie.secure", Value: strconv.FormatBool(c.Auth.Cookie.Secure), Type: "bool", Description: "Require HTTPS for cookies", Source: settingSource("TMI_COOKIE_SECURE"), EnvVar: "TMI_COOKIE_SECURE"}, - ) - return settings -} - -// getMigratableDatabaseSettings returns database configuration settings -// SEM@2efe458db50a86b8f77bc2b6f5938a5d15cc4315: list database connection and Redis settings eligible for database migration (pure) -func (c *Config) getMigratableDatabaseSettings() []MigratableSetting { - settings := []MigratableSetting{ - {Key: "database.url", Value: sanitizeURL(c.Database.URL), Type: "string", Description: "Database connection URL (password redacted)", Source: settingSource("TMI_DATABASE_URL"), EnvVar: "TMI_DATABASE_URL"}, - } - // Connection pool - settings = append(settings, - MigratableSetting{Key: "database.connection_pool.max_open_conns", Value: strconv.Itoa(c.Database.ConnectionPool.MaxOpenConns), Type: "int", Description: "Maximum open database connections", Source: settingSource("TMI_DB_MAX_OPEN_CONNS"), EnvVar: "TMI_DB_MAX_OPEN_CONNS"}, - MigratableSetting{Key: "database.connection_pool.max_idle_conns", Value: strconv.Itoa(c.Database.ConnectionPool.MaxIdleConns), Type: "int", Description: "Maximum idle database connections", Source: settingSource("TMI_DB_MAX_IDLE_CONNS"), EnvVar: "TMI_DB_MAX_IDLE_CONNS"}, - MigratableSetting{Key: "database.connection_pool.conn_max_lifetime", Value: strconv.Itoa(c.Database.ConnectionPool.ConnMaxLifetime), Type: "int", Description: "Max connection lifetime in seconds", Source: settingSource("TMI_DB_CONN_MAX_LIFETIME"), EnvVar: "TMI_DB_CONN_MAX_LIFETIME"}, - MigratableSetting{Key: "database.connection_pool.conn_max_idle_time", Value: strconv.Itoa(c.Database.ConnectionPool.ConnMaxIdleTime), Type: "int", Description: "Max connection idle time in seconds", Source: settingSource("TMI_DB_CONN_MAX_IDLE_TIME"), EnvVar: "TMI_DB_CONN_MAX_IDLE_TIME"}, - ) - // Redis - if c.Database.Redis.URL != "" { - settings = append(settings, MigratableSetting{Key: "database.redis.url", Value: sanitizeURL(c.Database.Redis.URL), Type: "string", Description: "Redis connection URL (password redacted)", Source: settingSource("TMI_REDIS_URL"), EnvVar: "TMI_REDIS_URL"}) - } - settings = append(settings, - MigratableSetting{Key: "database.redis.host", Value: c.Database.Redis.Host, Type: "string", Description: "Redis host", Source: settingSource("TMI_REDIS_HOST"), EnvVar: "TMI_REDIS_HOST"}, - MigratableSetting{Key: "database.redis.port", Value: c.Database.Redis.Port, Type: "string", Description: "Redis port", Source: settingSource("TMI_REDIS_PORT"), EnvVar: "TMI_REDIS_PORT"}, - MigratableSetting{Key: "database.redis.password", Value: c.Database.Redis.Password, Type: "string", Description: "Redis password", Source: settingSource("TMI_REDIS_PASSWORD"), Secret: true, EnvVar: "TMI_REDIS_PASSWORD"}, - MigratableSetting{Key: "database.redis.db", Value: strconv.Itoa(c.Database.Redis.DB), Type: "int", Description: "Redis database number", Source: settingSource("TMI_REDIS_DB"), EnvVar: "TMI_REDIS_DB"}, - ) - return settings -} - -// getMigratableFeatureFlags returns feature flag settings -// SEM@2efe458db50a86b8f77bc2b6f5938a5d15cc4315: list feature-flag settings eligible for database migration (pure) -func (c *Config) getMigratableFeatureFlags() []MigratableSetting { - return []MigratableSetting{ - { - Key: "features.saml_enabled", - Value: strconv.FormatBool(c.Auth.SAML.Enabled), - Type: "bool", - Description: "Enable SAML authentication", - Source: settingSource("TMI_SAML_ENABLED"), - EnvVar: "TMI_SAML_ENABLED", - }, - } -} - -// getMigratableOAuthSettings returns OAuth provider settings -// SEM@98d8987e5ea3f04c1ab0ce15c2ebfc210f3794db: build migratable settings list for OAuth callback URL and enabled providers (pure) +// getMigratableOAuthSettings returns settings generated per configured OAuth +// provider (auth.oauth.providers.*). auth.oauth_callback_url and +// auth.oauth.client_callback_allowlist are now projected from the registry +// in GetMigratableSettings; this covers only the provider subtree, which has +// dynamic cardinality and no SettingDef by design. +// SEM@71b4a22251f6acd8a9fb37257d5938c174392b09: build migratable settings list for every enabled OAuth provider (pure) func (c *Config) getMigratableOAuthSettings() []MigratableSetting { settings := []MigratableSetting{} - // OAuth callback URL (non-sensitive, useful for diagnostics). - // - // Source MUST go through settingSource, not a hardcoded "config". This is an - // operational (database-backed) key, and SettingsService.getConfigSetting - // discards any operational setting that is not Explicit — so a hardcoded - // "config" made GetString("auth.oauth_callback_url") always fall through to - // the database, no matter what the operator set. On AWS that let a stale - // http://localhost:8080/oauth2/callback row outrank a correctly-set - // TMI_OAUTH_CALLBACK_URL, and every provider (Google, GitHub, Microsoft) - // advertised a localhost redirect_uri, so OAuth sign-in could not complete. - // - // Only TMI_OAUTH_CALLBACK_URL is named, matching the OAuthConfig.CallbackURL - // struct tag. auth/config.go additionally honors a legacy OAUTH_CALLBACK_URL, - // but this package does not bind it, so counting it here would report - // "environment" while Value was still the localhost default — making that - // default Explicit and letting it outrank a correct database row. - if c.Auth.OAuth.CallbackURL != "" { - settings = append(settings, MigratableSetting{ - Key: "auth.oauth_callback_url", - Value: c.Auth.OAuth.CallbackURL, - Type: "string", - Description: "OAuth callback URL", - Source: settingSource("TMI_OAUTH_CALLBACK_URL"), - EnvVar: "TMI_OAUTH_CALLBACK_URL", - }) - } - - // OAuth client_callback allowlist (operational; read at request time by - // the auth handler to validate the client_callback query parameter). - if len(c.Auth.OAuth.ClientCallbackAllowList) > 0 { - allowJSON, _ := json.Marshal(c.Auth.OAuth.ClientCallbackAllowList) - settings = append(settings, MigratableSetting{ - Key: "auth.oauth.client_callback_allowlist", - Value: string(allowJSON), - Type: "json", - Description: "Allowlist of client_callback URLs for /oauth2/authorize and /oauth2/step_up (exact URL or wildcard pattern ending in '*')", - Source: settingSource("TMI_OAUTH_CLIENT_CALLBACK_ALLOWLIST"), - EnvVar: "TMI_OAUTH_CLIENT_CALLBACK_ALLOWLIST", - }) - } - - // OAuth provider settings for providerKey, p := range c.Auth.OAuth.Providers { if !p.Enabled { continue @@ -412,165 +348,6 @@ func (c *Config) getMigratableSAMLProviderSettings(providerKey string, p SAMLPro return settings } -// getMigratableRuntimeSettings returns runtime-configurable settings -// SEM@2efe458db50a86b8f77bc2b6f5938a5d15cc4315: build migratable settings list for WebSocket timeout, JWT expiry, and operator identity (pure) -func (c *Config) getMigratableRuntimeSettings() []MigratableSetting { - settings := []MigratableSetting{} - - // WebSocket settings - if c.WebSocket.InactivityTimeoutSeconds > 0 { - settings = append(settings, MigratableSetting{ - Key: "websocket.inactivity_timeout_seconds", - Value: strconv.Itoa(c.WebSocket.InactivityTimeoutSeconds), - Type: "int", - Description: "WebSocket inactivity timeout in seconds", - Source: settingSource("TMI_WEBSOCKET_INACTIVITY_TIMEOUT_SECONDS"), - EnvVar: "TMI_WEBSOCKET_INACTIVITY_TIMEOUT_SECONDS", - }) - } - - // JWT settings - if c.Auth.JWT.ExpirationSeconds > 0 { - settings = append(settings, MigratableSetting{ - Key: "session.timeout_minutes", - Value: strconv.Itoa(c.Auth.JWT.ExpirationSeconds / 60), - Type: "int", - Description: "JWT token expiration in minutes", - Source: settingSource("TMI_JWT_EXPIRATION_SECONDS"), - EnvVar: "TMI_JWT_EXPIRATION_SECONDS", - }) - } - - // Operator settings - if c.Operator.Name != "" { - settings = append(settings, MigratableSetting{ - Key: "operator.name", - Value: c.Operator.Name, - Type: "string", - Description: "Operator/maintainer name", - Source: settingSource("TMI_OPERATOR_NAME"), - EnvVar: "TMI_OPERATOR_NAME", - }) - } - if c.Operator.Contact != "" { - settings = append(settings, MigratableSetting{ - Key: "operator.contact", - Value: c.Operator.Contact, - Type: "string", - Description: "Operator contact information", - Source: settingSource("TMI_OPERATOR_CONTACT"), - EnvVar: "TMI_OPERATOR_CONTACT", - }) - } - if c.Operator.Jurisdiction != "" { - settings = append(settings, MigratableSetting{ - Key: "operator.jurisdiction", - Value: c.Operator.Jurisdiction, - Type: "string", - Description: "Legal jurisdiction under which the service operates", - Source: settingSource("TMI_OPERATOR_JURISDICTION"), - EnvVar: "TMI_OPERATOR_JURISDICTION", - }) - } - - return settings -} - -// getMigratableLoggingSettings returns logging configuration settings -// SEM@2efe458db50a86b8f77bc2b6f5938a5d15cc4315: build migratable settings list for all logging configuration parameters (pure) -func (c *Config) getMigratableLoggingSettings() []MigratableSetting { - return []MigratableSetting{ - {Key: "logging.level", Value: c.Logging.Level, Type: "string", Description: "Log level", Source: settingSource("TMI_LOG_LEVEL"), EnvVar: "TMI_LOG_LEVEL"}, - {Key: "logging.is_dev", Value: strconv.FormatBool(c.Logging.IsDev), Type: "bool", Description: "Development mode logging", Source: settingSource("TMI_LOG_IS_DEV"), EnvVar: "TMI_LOG_IS_DEV"}, - {Key: "logging.is_test", Value: strconv.FormatBool(c.Logging.IsTest), Type: "bool", Description: "Test mode logging", Source: settingSource("TMI_LOG_IS_TEST"), EnvVar: "TMI_LOG_IS_TEST"}, - {Key: "logging.log_dir", Value: c.Logging.LogDir, Type: "string", Description: "Log directory", Source: settingSource("TMI_LOG_DIR"), EnvVar: "TMI_LOG_DIR"}, - {Key: "logging.max_age_days", Value: strconv.Itoa(c.Logging.MaxAgeDays), Type: "int", Description: "Log max age in days", Source: settingSource("TMI_LOG_MAX_AGE_DAYS"), EnvVar: "TMI_LOG_MAX_AGE_DAYS"}, - {Key: "logging.max_size_mb", Value: strconv.Itoa(c.Logging.MaxSizeMB), Type: "int", Description: "Log max size in MB", Source: settingSource("TMI_LOG_MAX_SIZE_MB"), EnvVar: "TMI_LOG_MAX_SIZE_MB"}, - {Key: "logging.max_backups", Value: strconv.Itoa(c.Logging.MaxBackups), Type: "int", Description: "Log max backup count", Source: settingSource("TMI_LOG_MAX_BACKUPS"), EnvVar: "TMI_LOG_MAX_BACKUPS"}, - {Key: "logging.also_log_to_console", Value: strconv.FormatBool(c.Logging.AlsoLogToConsole), Type: "bool", Description: "Also log to console", Source: settingSource("TMI_LOG_ALSO_LOG_TO_CONSOLE"), EnvVar: "TMI_LOG_ALSO_LOG_TO_CONSOLE"}, - {Key: "logging.cloud_error_threshold", Value: strconv.Itoa(c.Logging.CloudErrorThreshold), Type: "int", Description: "Cloud sink consecutive-failure threshold for one-shot Warn alarm (0 disables)", Source: settingSource("TMI_LOG_CLOUD_ERROR_THRESHOLD"), EnvVar: "TMI_LOG_CLOUD_ERROR_THRESHOLD"}, - {Key: "logging.log_api_requests", Value: strconv.FormatBool(c.Logging.LogAPIRequests), Type: "bool", Description: "Log API requests", Source: settingSource("TMI_LOG_API_REQUESTS"), EnvVar: "TMI_LOG_API_REQUESTS"}, - {Key: "logging.log_api_responses", Value: strconv.FormatBool(c.Logging.LogAPIResponses), Type: "bool", Description: "Log API responses", Source: settingSource("TMI_LOG_API_RESPONSES"), EnvVar: "TMI_LOG_API_RESPONSES"}, - {Key: "logging.log_websocket_messages", Value: strconv.FormatBool(c.Logging.LogWebSocketMsg), Type: "bool", Description: "Log WebSocket messages", Source: settingSource("TMI_LOG_WEBSOCKET_MESSAGES"), EnvVar: "TMI_LOG_WEBSOCKET_MESSAGES"}, - {Key: "logging.redact_auth_tokens", Value: strconv.FormatBool(c.Logging.RedactAuthTokens), Type: "bool", Description: "Redact auth tokens in logs", Source: settingSource("TMI_LOG_REDACT_AUTH_TOKENS"), EnvVar: "TMI_LOG_REDACT_AUTH_TOKENS"}, - {Key: "logging.suppress_unauthenticated_logs", Value: strconv.FormatBool(c.Logging.SuppressUnauthenticatedLogs), Type: "bool", Description: "Suppress unauthenticated request logs", Source: settingSource("TMI_LOG_SUPPRESS_UNAUTH_LOGS"), EnvVar: "TMI_LOG_SUPPRESS_UNAUTH_LOGS"}, - } -} - -// getMigratableSecretsSettings returns secrets provider configuration settings -// SEM@2efe458db50a86b8f77bc2b6f5938a5d15cc4315: build migratable settings list for secrets provider type and backend coordinates (pure) -func (c *Config) getMigratableSecretsSettings() []MigratableSetting { - settings := []MigratableSetting{ - {Key: "secrets.provider", Value: c.Secrets.Provider, Type: "string", Description: "Secret provider type", Source: settingSource("TMI_SECRETS_PROVIDER"), EnvVar: "TMI_SECRETS_PROVIDER"}, - } - stringFields := []struct{ key, value, env, desc string }{ - {"secrets.vault_address", c.Secrets.VaultAddress, "TMI_VAULT_ADDRESS", "HashiCorp Vault address"}, - {"secrets.vault_path", c.Secrets.VaultPath, "TMI_VAULT_PATH", "HashiCorp Vault path"}, - {"secrets.aws_region", c.Secrets.AWSRegion, "TMI_AWS_REGION", "AWS region"}, - {"secrets.aws_secret_name", c.Secrets.AWSSecretName, "TMI_AWS_SECRET_NAME", "AWS secret name"}, - {"secrets.azure_vault_url", c.Secrets.AzureVaultURL, "TMI_AZURE_VAULT_URL", "Azure Key Vault URL"}, - {"secrets.gcp_project_id", c.Secrets.GCPProjectID, "TMI_GCP_PROJECT_ID", "GCP project ID"}, - {"secrets.gcp_secret_name", c.Secrets.GCPSecretName, "TMI_GCP_SECRET_NAME", "GCP secret name"}, - {"secrets.oci_compartment_id", c.Secrets.OCICompartmentID, "TMI_OCI_COMPARTMENT_ID", "OCI compartment ID"}, - {"secrets.oci_vault_id", c.Secrets.OCIVaultID, "TMI_OCI_VAULT_ID", "OCI vault ID"}, - {"secrets.oci_secret_name", c.Secrets.OCISecretName, "TMI_OCI_SECRET_NAME", "OCI secret name"}, - } - for _, f := range stringFields { - if f.value != "" { - settings = append(settings, MigratableSetting{Key: f.key, Value: f.value, Type: "string", Description: f.desc, Source: settingSource(f.env), EnvVar: f.env}) - } - } - settings = append(settings, MigratableSetting{Key: "secrets.vault_token", Value: c.Secrets.VaultToken, Type: "string", Description: "HashiCorp Vault token", Source: settingSource("TMI_VAULT_TOKEN"), Secret: true, EnvVar: "TMI_VAULT_TOKEN"}) - return settings -} - -// getMigratableTimmySettings returns Timmy AI assistant settings, including -// the shared embedding profile keys. -// SEM@f7cc4344884e20bc7f6fb9a5815e2e1d530c0ff6: build migratable settings list for Timmy AI assistant LLM, embedding, and session parameters (pure) -func (c *Config) getMigratableTimmySettings() []MigratableSetting { - t := c.Timmy - settings := []MigratableSetting{ - {Key: "timmy.enabled", Value: strconv.FormatBool(t.Enabled), Type: "bool", Description: "Timmy AI assistant enabled", Source: settingSource("TMI_TIMMY_ENABLED"), EnvVar: "TMI_TIMMY_ENABLED"}, - {Key: "timmy.llm_provider", Value: t.LLMProvider, Type: "string", Description: "LLM provider", Source: settingSource("TMI_TIMMY_LLM_PROVIDER"), EnvVar: "TMI_TIMMY_LLM_PROVIDER"}, - {Key: "timmy.llm_model", Value: t.LLMModel, Type: "string", Description: "LLM model", Source: settingSource("TMI_TIMMY_LLM_MODEL"), EnvVar: "TMI_TIMMY_LLM_MODEL"}, - {Key: "timmy.llm_api_key", Value: t.LLMAPIKey, Type: "string", Description: "LLM API key", Source: settingSource("TMI_TIMMY_LLM_API_KEY"), Secret: true, EnvVar: "TMI_TIMMY_LLM_API_KEY"}, - {Key: "timmy.llm_base_url", Value: t.LLMBaseURL, Type: "string", Description: "LLM API base URL", Source: settingSource("TMI_TIMMY_LLM_BASE_URL"), EnvVar: "TMI_TIMMY_LLM_BASE_URL"}, - {Key: "timmy.llm_max_tokens", Value: strconv.Itoa(t.LLMMaxTokens), Type: "int", Description: "Max tokens per chat completion (required by Anthropic; optional for OpenAI)", Source: settingSource("TMI_TIMMY_LLM_MAX_TOKENS"), EnvVar: "TMI_TIMMY_LLM_MAX_TOKENS"}, - {Key: "timmy.text_embedding_provider", Value: t.TextEmbeddingProvider, Type: "string", Description: "Text embedding provider", Source: settingSource("TMI_TIMMY_TEXT_EMBEDDING_PROVIDER"), EnvVar: "TMI_TIMMY_TEXT_EMBEDDING_PROVIDER"}, - {Key: "timmy.text_embedding_model", Value: t.TextEmbeddingModel, Type: "string", Description: "Text embedding model — shared invariant between ingest and query", Source: settingSource("TMI_TIMMY_TEXT_EMBEDDING_MODEL"), EnvVar: "TMI_TIMMY_TEXT_EMBEDDING_MODEL"}, - {Key: "timmy.text_embedding_base_url", Value: t.TextEmbeddingBaseURL, Type: "string", Description: "Text embedding API base URL — shared invariant", Source: settingSource("TMI_TIMMY_TEXT_EMBEDDING_BASE_URL"), EnvVar: "TMI_TIMMY_TEXT_EMBEDDING_BASE_URL"}, - {Key: "timmy.embedding_dimension", Value: strconv.Itoa(t.EmbeddingDimension), Type: "int", Description: "Text embedding vector dimension — shared invariant", Source: settingSource("TMI_TIMMY_EMBEDDING_DIMENSION"), EnvVar: "TMI_TIMMY_EMBEDDING_DIMENSION"}, - {Key: "timmy.text_embedding_api_key", Value: t.TextEmbeddingAPIKey, Type: "string", Description: "Text embedding API key", Source: settingSource("TMI_TIMMY_TEXT_EMBEDDING_API_KEY"), Secret: true, EnvVar: "TMI_TIMMY_TEXT_EMBEDDING_API_KEY"}, - {Key: "timmy.text_retrieval_top_k", Value: strconv.Itoa(t.TextRetrievalTopK), Type: "int", Description: "Text retrieval top-k results", Source: settingSource("TMI_TIMMY_TEXT_RETRIEVAL_TOP_K"), EnvVar: "TMI_TIMMY_TEXT_RETRIEVAL_TOP_K"}, - {Key: "timmy.code_embedding_provider", Value: t.CodeEmbeddingProvider, Type: "string", Description: "Code embedding provider", Source: settingSource("TMI_TIMMY_CODE_EMBEDDING_PROVIDER"), EnvVar: "TMI_TIMMY_CODE_EMBEDDING_PROVIDER"}, - {Key: "timmy.code_embedding_model", Value: t.CodeEmbeddingModel, Type: "string", Description: "Code embedding model", Source: settingSource("TMI_TIMMY_CODE_EMBEDDING_MODEL"), EnvVar: "TMI_TIMMY_CODE_EMBEDDING_MODEL"}, - {Key: "timmy.code_embedding_api_key", Value: t.CodeEmbeddingAPIKey, Type: "string", Description: "Code embedding API key", Source: settingSource("TMI_TIMMY_CODE_EMBEDDING_API_KEY"), Secret: true, EnvVar: "TMI_TIMMY_CODE_EMBEDDING_API_KEY"}, - {Key: "timmy.code_embedding_base_url", Value: t.CodeEmbeddingBaseURL, Type: "string", Description: "Code embedding API base URL", Source: settingSource("TMI_TIMMY_CODE_EMBEDDING_BASE_URL"), EnvVar: "TMI_TIMMY_CODE_EMBEDDING_BASE_URL"}, - {Key: "timmy.code_retrieval_top_k", Value: strconv.Itoa(t.CodeRetrievalTopK), Type: "int", Description: "Code retrieval top-k results", Source: settingSource("TMI_TIMMY_CODE_RETRIEVAL_TOP_K"), EnvVar: "TMI_TIMMY_CODE_RETRIEVAL_TOP_K"}, - {Key: "timmy.query_decomposition_enabled", Value: strconv.FormatBool(t.QueryDecompositionEnabled), Type: "bool", Description: "Query decomposition enabled", Source: settingSource("TMI_TIMMY_QUERY_DECOMPOSITION_ENABLED"), EnvVar: "TMI_TIMMY_QUERY_DECOMPOSITION_ENABLED"}, - {Key: "timmy.rerank_provider", Value: t.RerankProvider, Type: "string", Description: "Reranker provider", Source: settingSource("TMI_TIMMY_RERANK_PROVIDER"), EnvVar: "TMI_TIMMY_RERANK_PROVIDER"}, - {Key: "timmy.rerank_model", Value: t.RerankModel, Type: "string", Description: "Reranker model", Source: settingSource("TMI_TIMMY_RERANK_MODEL"), EnvVar: "TMI_TIMMY_RERANK_MODEL"}, - {Key: "timmy.rerank_api_key", Value: t.RerankAPIKey, Type: "string", Description: "Reranker API key", Source: settingSource("TMI_TIMMY_RERANK_API_KEY"), Secret: true, EnvVar: "TMI_TIMMY_RERANK_API_KEY"}, - {Key: "timmy.rerank_base_url", Value: t.RerankBaseURL, Type: "string", Description: "Reranker API base URL", Source: settingSource("TMI_TIMMY_RERANK_BASE_URL"), EnvVar: "TMI_TIMMY_RERANK_BASE_URL"}, - {Key: "timmy.rerank_top_k", Value: strconv.Itoa(t.RerankTopK), Type: "int", Description: "Reranker top-k results", Source: settingSource("TMI_TIMMY_RERANK_TOP_K"), EnvVar: "TMI_TIMMY_RERANK_TOP_K"}, - {Key: "timmy.max_conversation_history", Value: strconv.Itoa(t.MaxConversationHistory), Type: "int", Description: "Max conversation history entries", Source: settingSource("TMI_TIMMY_MAX_CONVERSATION_HISTORY"), EnvVar: "TMI_TIMMY_MAX_CONVERSATION_HISTORY"}, - {Key: "timmy.operator_system_prompt", Value: t.OperatorSystemPrompt, Type: "string", Description: "Operator system prompt override", Source: settingSource("TMI_TIMMY_OPERATOR_SYSTEM_PROMPT"), EnvVar: "TMI_TIMMY_OPERATOR_SYSTEM_PROMPT"}, - {Key: "timmy.max_memory_mb", Value: strconv.Itoa(t.MaxMemoryMB), Type: "int", Description: "Max memory in MB", Source: settingSource("TMI_TIMMY_MAX_MEMORY_MB"), EnvVar: "TMI_TIMMY_MAX_MEMORY_MB"}, - {Key: "timmy.inactivity_timeout_seconds", Value: strconv.Itoa(t.InactivityTimeoutSeconds), Type: "int", Description: "Session inactivity timeout in seconds", Source: settingSource("TMI_TIMMY_INACTIVITY_TIMEOUT_SECONDS"), EnvVar: "TMI_TIMMY_INACTIVITY_TIMEOUT_SECONDS"}, - {Key: "timmy.max_messages_per_user_per_hour", Value: strconv.Itoa(t.MaxMessagesPerUserPerHour), Type: "int", Description: "Max messages per user per hour", Source: settingSource("TMI_TIMMY_MAX_MESSAGES_PER_USER_PER_HOUR"), EnvVar: "TMI_TIMMY_MAX_MESSAGES_PER_USER_PER_HOUR"}, - {Key: "timmy.max_sessions_per_threat_model", Value: strconv.Itoa(t.MaxSessionsPerThreatModel), Type: "int", Description: "Max Timmy sessions per threat model", Source: settingSource("TMI_TIMMY_MAX_SESSIONS_PER_THREAT_MODEL"), EnvVar: "TMI_TIMMY_MAX_SESSIONS_PER_THREAT_MODEL"}, - {Key: "timmy.max_concurrent_llm_requests", Value: strconv.Itoa(t.MaxConcurrentLLMRequests), Type: "int", Description: "Max concurrent LLM requests", Source: settingSource("TMI_TIMMY_MAX_CONCURRENT_LLM_REQUESTS"), EnvVar: "TMI_TIMMY_MAX_CONCURRENT_LLM_REQUESTS"}, - {Key: "timmy.chunk_size", Value: strconv.Itoa(t.ChunkSize), Type: "int", Description: "Embedding chunk size", Source: settingSource("TMI_TIMMY_CHUNK_SIZE"), EnvVar: "TMI_TIMMY_CHUNK_SIZE"}, - {Key: "timmy.chunk_overlap", Value: strconv.Itoa(t.ChunkOverlap), Type: "int", Description: "Embedding chunk overlap", Source: settingSource("TMI_TIMMY_CHUNK_OVERLAP"), EnvVar: "TMI_TIMMY_CHUNK_OVERLAP"}, - {Key: "timmy.llm_timeout_seconds", Value: strconv.Itoa(t.LLMTimeoutSeconds), Type: "int", Description: "LLM request timeout in seconds", Source: settingSource("TMI_TIMMY_LLM_TIMEOUT_SECONDS"), EnvVar: "TMI_TIMMY_LLM_TIMEOUT_SECONDS"}, - {Key: "timmy.embedding_cleanup_interval_minutes", Value: strconv.Itoa(t.EmbeddingCleanupIntervalMinutes), Type: "int", Description: "Embedding cleanup interval in minutes", Source: settingSource("TMI_TIMMY_EMBEDDING_CLEANUP_INTERVAL_MINUTES"), EnvVar: "TMI_TIMMY_EMBEDDING_CLEANUP_INTERVAL_MINUTES"}, - {Key: "timmy.embedding_idle_days_active", Value: strconv.Itoa(t.EmbeddingIdleDaysActive), Type: "int", Description: "Days before idle active-TM embeddings are cleaned up", Source: settingSource("TMI_TIMMY_EMBEDDING_IDLE_DAYS_ACTIVE"), EnvVar: "TMI_TIMMY_EMBEDDING_IDLE_DAYS_ACTIVE"}, - {Key: "timmy.embedding_idle_days_closed", Value: strconv.Itoa(t.EmbeddingIdleDaysClosed), Type: "int", Description: "Days before idle closed-TM embeddings are cleaned up", Source: settingSource("TMI_TIMMY_EMBEDDING_IDLE_DAYS_CLOSED"), EnvVar: "TMI_TIMMY_EMBEDDING_IDLE_DAYS_CLOSED"}, - {Key: "timmy.dump_extracted_text_to_note", Value: strconv.FormatBool(t.DumpExtractedTextToNote), Type: "bool", Description: "Dump extracted text to note (dev/test only)", Source: settingSource("TMI_TIMMY_DUMP_EXTRACTED_TEXT_TO_NOTE"), EnvVar: "TMI_TIMMY_DUMP_EXTRACTED_TEXT_TO_NOTE"}, - } - return settings -} - // DefaultOperationalSettings returns the operational-category settings from a // default Config. It is the seed source for the DB-backed settings service. // Bootstrap-category settings are intentionally excluded — they are file/env @@ -587,129 +364,14 @@ func DefaultOperationalSettings() []MigratableSetting { return out } -// getMigratableAdministratorsSettings returns administrator configuration settings -// SEM@ef979cc7527137e0448782341a4ffe8e944571f5: build migratable settings list encoding the configured administrators as JSON (pure) -func (c *Config) getMigratableAdministratorsSettings() []MigratableSetting { - if len(c.Administrators) == 0 { - return nil - } - adminsJSON, err := json.Marshal(c.Administrators) - if err != nil { - return nil - } - return []MigratableSetting{ - {Key: "administrators", Value: string(adminsJSON), Type: "json", Description: "Configured administrators", Source: "config"}, - } -} - -// getMigratableObservabilitySettings returns OpenTelemetry / Prometheus settings. -// SEM@1a4ca5f99be4a25df66b2836e9b9f4c87628184a: build migratable settings list for OpenTelemetry and Prometheus observability parameters (pure) -func (c *Config) getMigratableObservabilitySettings() []MigratableSetting { - return []MigratableSetting{ - {Key: "observability.enabled", Value: strconv.FormatBool(c.Observability.Enabled), Type: "bool", Description: "OpenTelemetry tracing enabled", Source: settingSource("TMI_OTEL_ENABLED"), EnvVar: "TMI_OTEL_ENABLED"}, - {Key: "observability.prometheus_port", Value: strconv.Itoa(c.Observability.PrometheusPort), Type: "int", Description: "Prometheus metrics port (0 = disabled)", Source: settingSource("TMI_OTEL_PROMETHEUS_PORT"), EnvVar: "TMI_OTEL_PROMETHEUS_PORT"}, - // Type is "float", not "string": Observability.SamplingRate is a float64, - // and mislabelling it made --export-config emit a quoted `"1"` that - // --import-config could not unmarshal back into the field (#791). - {Key: "observability.sampling_rate", Value: strconv.FormatFloat(c.Observability.SamplingRate, 'f', -1, 64), Type: "float", Description: "OpenTelemetry trace sampling rate (0.0–1.0)", Source: settingSource("TMI_OTEL_SAMPLING_RATE"), EnvVar: "TMI_OTEL_SAMPLING_RATE"}, - } -} - -// getMigratableSSRFSettings returns SSRF protection allowlist settings. -// -// Allowlist and schemes fields are security-sensitive: they gate outbound HTTP -// calls to external systems. An empty allowlist is fail-closed (no hosts -// permitted), so we deliberately emit only when non-empty to avoid seeding an -// empty-string CLOB row on Oracle (which is indistinguishable from NULL and -// could silently widen the allowlist to all hosts on misconfigured installs). -// The runtime SSRF validator already defaults to fail-closed when the setting -// is absent from the DB. -// SEM@65c1476330c2d49ec6f16949e34340248686bdd4: build migratable settings list for SSRF allowlist and permitted schemes per URI class (pure) -func (c *Config) getMigratableSSRFSettings() []MigratableSetting { - // SEM@65c1476330c2d49ec6f16949e34340248686bdd4: pair an SSRF config key prefix with its URIConfig value for iteration (pure) - type ssrfEntry struct { - prefix string - cfg SSRFURIConfig - } - entries := []ssrfEntry{ - {"ssrf.issue_uri", c.SSRF.IssueURI}, - {"ssrf.document_uri", c.SSRF.DocumentURI}, - {"ssrf.repository_uri", c.SSRF.RepositoryURI}, - {"ssrf.timmy", c.SSRF.Timmy}, - {"ssrf.webhook", c.SSRF.Webhook}, - } - settings := []MigratableSetting{} - for _, e := range entries { - // Only emit when the operator has explicitly configured the field; - // empty string is fail-closed (no hosts allowed) and must not be - // seeded as an empty CLOB row on Oracle. - if e.cfg.Allowlist != "" { - settings = append(settings, MigratableSetting{ - Key: e.prefix + ".allowlist", - Value: e.cfg.Allowlist, - Type: "string", - Description: "SSRF allowlist for " + e.prefix + " (comma-separated host patterns)", - Source: "config", - }) - } - if e.cfg.Schemes != "" { - settings = append(settings, MigratableSetting{ - Key: e.prefix + ".schemes", - Value: e.cfg.Schemes, - Type: "string", - Description: "Permitted URI schemes for " + e.prefix + " (comma-separated, e.g. https)", - Source: "config", - }) - } - } - return settings -} - -// getMigratableWebhooksSettings returns webhook configuration settings. -// SEM@2efe458db50a86b8f77bc2b6f5938a5d15cc4315: build migratable settings list for webhook HTTP target policy (pure) -func (c *Config) getMigratableWebhooksSettings() []MigratableSetting { - return []MigratableSetting{ - {Key: "webhooks.allow_http_targets", Value: strconv.FormatBool(c.Webhooks.AllowHTTPTargets), Type: "bool", Description: "Allow non-HTTPS webhook target URLs (intra-cluster use only)", Source: settingSource("TMI_WEBHOOK_ALLOW_HTTP_TARGETS"), EnvVar: "TMI_WEBHOOK_ALLOW_HTTP_TARGETS"}, - } -} - -// getMigratableContentExtractorsSettings returns OOXML extractor pipeline limits. -// SEM@d34da3918d4a3784077a74aedd722e45c29196cf: build migratable settings list for content extractor size and concurrency limits (pure) -func (c *Config) getMigratableContentExtractorsSettings() []MigratableSetting { - e := c.ContentExtractors - return []MigratableSetting{ - {Key: "content_extractors.compressed_size_bytes", Value: strconv.FormatInt(e.CompressedSizeBytes, 10), Type: "int", Description: "Max compressed upload size in bytes", Source: settingSource("TMI_CONTENT_EXTRACTORS_COMPRESSED_SIZE_BYTES"), EnvVar: "TMI_CONTENT_EXTRACTORS_COMPRESSED_SIZE_BYTES"}, - {Key: "content_extractors.decompressed_size_bytes", Value: strconv.FormatInt(e.DecompressedSizeBytes, 10), Type: "int", Description: "Max decompressed content size in bytes", Source: settingSource("TMI_CONTENT_EXTRACTORS_DECOMPRESSED_SIZE_BYTES"), EnvVar: "TMI_CONTENT_EXTRACTORS_DECOMPRESSED_SIZE_BYTES"}, - {Key: "content_extractors.part_size_bytes", Value: strconv.FormatInt(e.PartSizeBytes, 10), Type: "int", Description: "Max size of a single archive part in bytes", Source: settingSource("TMI_CONTENT_EXTRACTORS_PART_SIZE_BYTES"), EnvVar: "TMI_CONTENT_EXTRACTORS_PART_SIZE_BYTES"}, - {Key: "content_extractors.pptx_slides", Value: strconv.Itoa(e.PPTXSlides), Type: "int", Description: "Max number of PowerPoint slides to extract", Source: settingSource("TMI_CONTENT_EXTRACTORS_PPTX_SLIDES"), EnvVar: "TMI_CONTENT_EXTRACTORS_PPTX_SLIDES"}, - {Key: "content_extractors.xlsx_cells", Value: strconv.Itoa(e.XLSXCells), Type: "int", Description: "Max number of Excel cells to extract", Source: settingSource("TMI_CONTENT_EXTRACTORS_XLSX_CELLS"), EnvVar: "TMI_CONTENT_EXTRACTORS_XLSX_CELLS"}, - {Key: "content_extractors.markdown_size_bytes", Value: strconv.FormatInt(e.MarkdownSizeBytes, 10), Type: "int", Description: "Max markdown output size in bytes", Source: settingSource("TMI_CONTENT_EXTRACTORS_MARKDOWN_SIZE_BYTES"), EnvVar: "TMI_CONTENT_EXTRACTORS_MARKDOWN_SIZE_BYTES"}, - {Key: "content_extractors.wall_clock_budget", Value: e.WallClockBudget.String(), Type: "string", Description: "Max wall-clock time for a single extraction", Source: settingSource("TMI_CONTENT_EXTRACTORS_WALL_CLOCK_BUDGET"), EnvVar: "TMI_CONTENT_EXTRACTORS_WALL_CLOCK_BUDGET"}, - {Key: "content_extractors.per_user_concurrency_default", Value: strconv.Itoa(e.PerUserConcurrencyDefault), Type: "int", Description: "Default max concurrent extractions per user", Source: settingSource("TMI_CONTENT_EXTRACTORS_PER_USER_CONCURRENCY_DEFAULT"), EnvVar: "TMI_CONTENT_EXTRACTORS_PER_USER_CONCURRENCY_DEFAULT"}, - {Key: "extraction.async_enabled", Value: strconv.FormatBool(e.AsyncEnabled), Type: "bool", Description: "Route document extraction through the async worker pipeline instead of inline (default false; requires NATS)", Source: settingSource("TMI_EXTRACTION_ASYNC_ENABLED"), EnvVar: "TMI_EXTRACTION_ASYNC_ENABLED"}, - } -} - -// getMigratableContentOAuthSettings returns delegated content OAuth settings. -// -// callback_url is omitted when empty to avoid seeding an empty-string CLOB row -// on Oracle. content_oauth.providers.* are dynamic-cardinality and handled by -// the prefix classification; individual provider secrets already carry -// Secret:true in the per-provider helper below. -// SEM@2efe458db50a86b8f77bc2b6f5938a5d15cc4315: build migratable settings list for content OAuth callback URL and enabled provider credentials (pure) +// getMigratableContentOAuthSettings returns settings generated per configured +// content OAuth provider (content_oauth.providers.*). content_oauth.callback_url +// is now projected from the registry in GetMigratableSettings; this covers +// only the provider subtree, which has dynamic cardinality and no SettingDef +// by design. +// SEM@71b4a22251f6acd8a9fb37257d5938c174392b09: build migratable settings list for every enabled content OAuth provider (pure) func (c *Config) getMigratableContentOAuthSettings() []MigratableSetting { settings := []MigratableSetting{} - if c.ContentOAuth.CallbackURL != "" { - settings = append(settings, MigratableSetting{ - Key: "content_oauth.callback_url", - Value: c.ContentOAuth.CallbackURL, - Type: "string", - Description: "Content OAuth callback URL", - Source: settingSource("TMI_CONTENT_OAUTH_CALLBACK_URL"), - EnvVar: "TMI_CONTENT_OAUTH_CALLBACK_URL", - }) - } - // content_oauth.providers.* — per-provider helper for providerKey, p := range c.ContentOAuth.Providers { if !p.Enabled { continue @@ -761,89 +423,3 @@ func getMigratableContentOAuthProviderSettings(providerKey string, p ContentOAut } return settings } - -// getMigratableContentSourcesSettings returns content source provider settings. -// -// String fields that are filesystem paths or email addresses are omitted when -// empty (Oracle empty-CLOB safe). Boolean enabled flags and picker public IDs -// always emit since they have meaningful zero/sentinel values. -// SEM@2efe458db50a86b8f77bc2b6f5938a5d15cc4315: build migratable settings list for Google Drive, Google Workspace, Confluence, and Microsoft content source configuration (pure) -func (c *Config) getMigratableContentSourcesSettings() []MigratableSetting { - settings := []MigratableSetting{} - - // Google Drive - gd := c.ContentSources.GoogleDrive - settings = append(settings, - MigratableSetting{Key: "content_sources.google_drive.enabled", Value: strconv.FormatBool(gd.Enabled), Type: "bool", Description: "Google Drive content source enabled", Source: settingSource("TMI_CONTENT_SOURCE_GOOGLE_DRIVE_ENABLED"), EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_DRIVE_ENABLED"}, - ) - if gd.ServiceAccountEmail != "" { - settings = append(settings, MigratableSetting{Key: "content_sources.google_drive.service_account_email", Value: gd.ServiceAccountEmail, Type: "string", Description: "Google Drive service account email", Source: settingSource("TMI_CONTENT_SOURCE_GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL"), EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL"}) - } - if gd.CredentialsFile != "" { - settings = append(settings, MigratableSetting{Key: "content_sources.google_drive.credentials_file", Value: gd.CredentialsFile, Type: "string", Description: "Google Drive service account credentials file path", Source: settingSource("TMI_CONTENT_SOURCE_GOOGLE_DRIVE_CREDENTIALS_FILE"), EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_DRIVE_CREDENTIALS_FILE"}) - } - if gd.BrowserOAuthClientID != "" { - settings = append(settings, MigratableSetting{Key: "content_sources.google_drive.browser_oauth_client_id", Value: gd.BrowserOAuthClientID, Type: "string", Description: "Google Drive browser OAuth client ID (public)", Source: settingSource("TMI_CONTENT_SOURCE_GOOGLE_DRIVE_BROWSER_OAUTH_CLIENT_ID"), EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_DRIVE_BROWSER_OAUTH_CLIENT_ID"}) - } - if gd.PickerDeveloperKey != "" { - settings = append(settings, MigratableSetting{Key: "content_sources.google_drive.picker_developer_key", Value: gd.PickerDeveloperKey, Type: "string", Description: "Google Drive Picker developer key (public)", Source: settingSource("TMI_CONTENT_SOURCE_GOOGLE_DRIVE_PICKER_DEVELOPER_KEY"), EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_DRIVE_PICKER_DEVELOPER_KEY"}) - } - if gd.PickerAppID != "" { - settings = append(settings, MigratableSetting{Key: "content_sources.google_drive.picker_app_id", Value: gd.PickerAppID, Type: "string", Description: "Google Drive Picker app ID (public)", Source: settingSource("TMI_CONTENT_SOURCE_GOOGLE_DRIVE_PICKER_APP_ID"), EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_DRIVE_PICKER_APP_ID"}) - } - - // Google Workspace - gw := c.ContentSources.GoogleWorkspace - settings = append(settings, - MigratableSetting{Key: "content_sources.google_workspace.enabled", Value: strconv.FormatBool(gw.Enabled), Type: "bool", Description: "Google Workspace content source enabled", Source: settingSource("TMI_CONTENT_SOURCE_GOOGLE_WORKSPACE_ENABLED"), EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_WORKSPACE_ENABLED"}, - ) - if gw.PickerDeveloperKey != "" { - settings = append(settings, MigratableSetting{Key: "content_sources.google_workspace.picker_developer_key", Value: gw.PickerDeveloperKey, Type: "string", Description: "Google Workspace Picker developer key (public)", Source: settingSource("TMI_CONTENT_SOURCE_GOOGLE_WORKSPACE_PICKER_DEVELOPER_KEY"), EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_WORKSPACE_PICKER_DEVELOPER_KEY"}) - } - if gw.PickerAppID != "" { - settings = append(settings, MigratableSetting{Key: "content_sources.google_workspace.picker_app_id", Value: gw.PickerAppID, Type: "string", Description: "Google Workspace Picker app ID (public)", Source: settingSource("TMI_CONTENT_SOURCE_GOOGLE_WORKSPACE_PICKER_APP_ID"), EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_WORKSPACE_PICKER_APP_ID"}) - } - - // Confluence - cf := c.ContentSources.Confluence - settings = append(settings, - MigratableSetting{Key: "content_sources.confluence.enabled", Value: strconv.FormatBool(cf.Enabled), Type: "bool", Description: "Confluence content source enabled", Source: settingSource("TMI_CONTENT_SOURCE_CONFLUENCE_ENABLED"), EnvVar: "TMI_CONTENT_SOURCE_CONFLUENCE_ENABLED"}, - ) - - // Microsoft - ms := c.ContentSources.Microsoft - settings = append(settings, - MigratableSetting{Key: "content_sources.microsoft.enabled", Value: strconv.FormatBool(ms.Enabled), Type: "bool", Description: "Microsoft content source enabled", Source: settingSource("TMI_CONTENT_SOURCE_MICROSOFT_ENABLED"), EnvVar: "TMI_CONTENT_SOURCE_MICROSOFT_ENABLED"}, - ) - if ms.TenantID != "" { - settings = append(settings, MigratableSetting{Key: "content_sources.microsoft.tenant_id", Value: ms.TenantID, Type: "string", Description: "Microsoft Entra tenant ID", Source: settingSource("TMI_CONTENT_SOURCE_MICROSOFT_TENANT_ID"), EnvVar: "TMI_CONTENT_SOURCE_MICROSOFT_TENANT_ID"}) - } - if ms.ClientID != "" { - settings = append(settings, MigratableSetting{Key: "content_sources.microsoft.client_id", Value: ms.ClientID, Type: "string", Description: "Microsoft Entra app client ID (public)", Source: settingSource("TMI_CONTENT_SOURCE_MICROSOFT_CLIENT_ID"), EnvVar: "TMI_CONTENT_SOURCE_MICROSOFT_CLIENT_ID"}) - } - if ms.ApplicationObjectID != "" { - settings = append(settings, MigratableSetting{Key: "content_sources.microsoft.application_object_id", Value: ms.ApplicationObjectID, Type: "string", Description: "Microsoft Entra application object ID", Source: settingSource("TMI_CONTENT_SOURCE_MICROSOFT_APPLICATION_OBJECT_ID"), EnvVar: "TMI_CONTENT_SOURCE_MICROSOFT_APPLICATION_OBJECT_ID"}) - } - if ms.PickerOrigin != "" { - settings = append(settings, MigratableSetting{Key: "content_sources.microsoft.picker_origin", Value: ms.PickerOrigin, Type: "string", Description: "Microsoft Picker allowed origin URL", Source: settingSource("TMI_CONTENT_SOURCE_MICROSOFT_PICKER_ORIGIN"), EnvVar: "TMI_CONTENT_SOURCE_MICROSOFT_PICKER_ORIGIN"}) - } - - return settings -} - -// getMigratableAlertingSettings returns alerting / audit-alert-sink settings (#395). -// These are CategoryBootstrap because they are consumed at startup time by -// EnsurePinnedAlertSubscription, before the DB settings service is available. -// SEM@13c4215bf8e204da342579717f97f7393bb5fe2f: build migratable settings list for the audit alert sink webhook URL and HMAC secret (pure) -func (c *Config) getMigratableAlertingSettings() []MigratableSetting { - settings := []MigratableSetting{ - {Key: "alerting.enabled", Value: strconv.FormatBool(c.Alerting.Enabled), Type: "bool", Description: "Enable the operator-pinned audit alert sink webhook subscription (#395)", Source: settingSource("TMI_ALERTING_ENABLED"), EnvVar: "TMI_ALERTING_ENABLED"}, - } - if c.Alerting.WebhookURL != "" { - settings = append(settings, MigratableSetting{Key: "alerting.webhook_url", Value: c.Alerting.WebhookURL, Type: "string", Description: "URL of the audit alert sink webhook endpoint (#395)", Source: settingSource("TMI_ALERTING_WEBHOOK_URL"), EnvVar: "TMI_ALERTING_WEBHOOK_URL"}) - } - if c.Alerting.WebhookSecret != "" { - settings = append(settings, MigratableSetting{Key: "alerting.webhook_secret", Value: c.Alerting.WebhookSecret, Type: "string", Secret: true, Description: "HMAC signing secret for the audit alert sink webhook (#395)", Source: settingSource("TMI_ALERTING_WEBHOOK_SECRET"), EnvVar: "TMI_ALERTING_WEBHOOK_SECRET"}) - } - return settings -} diff --git a/internal/config/migratable_settings_equivalence_test.go b/internal/config/migratable_settings_equivalence_test.go new file mode 100644 index 00000000..27ef004a --- /dev/null +++ b/internal/config/migratable_settings_equivalence_test.go @@ -0,0 +1,295 @@ +package config + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// isGeneratedProviderKey reports whether a key comes from the per-provider +// generators (auth.oauth.providers.*, auth.saml.providers.*, +// content_oauth.providers.*) rather than from a static SettingDef. These +// subtrees have dynamic cardinality — one instance per configured provider — +// so they cannot have a fixed dotted-key SettingDef and are out of scope for +// the registry projection. +func isGeneratedProviderKey(key string) bool { + for _, p := range []string{ + "auth.oauth.providers.", + "auth.saml.providers.", + "content_oauth.providers.", + } { + if strings.HasPrefix(key, p) { + return true + } + } + return false +} + +// sampleConfig returns a Config with enough fields populated to exercise the +// OmitWhenEmpty conditional-emission paths (see OmitWhenEmpty's doc comment +// on SettingDef). +func sampleConfig() *Config { + c := &Config{} + c.Server.Port = "8080" + c.Server.Interface = "0.0.0.0" + c.Server.BaseURL = "https://api.example.test" + c.Server.TLSEnabled = true + c.Server.TLSCertFile = "/tmp/cert.pem" + c.Server.TLSKeyFile = "/tmp/key.pem" + c.Server.CORS.AllowedOrigins = []string{"https://ui.example.test"} + return c +} + +// TestGetMigratableSettings_ValuesComeFromConfig pins that projected values +// come from the live Config for a representative sample of types, including +// OmitWhenEmpty keys populated with a real value. +func TestGetMigratableSettings_ValuesComeFromConfig(t *testing.T) { + c := sampleConfig() + byKey := map[string]MigratableSetting{} + for _, s := range c.GetMigratableSettings() { + byKey[s.Key] = s + } + + require.Contains(t, byKey, "server.port") + assert.Equal(t, "8080", byKey["server.port"].Value) + assert.Equal(t, "TMI_SERVER_PORT", byKey["server.port"].EnvVar) + + require.Contains(t, byKey, "server.tls_enabled") + assert.Equal(t, "true", byKey["server.tls_enabled"].Value) + + require.Contains(t, byKey, "server.cors.allowed_origins") + assert.Equal(t, `["https://ui.example.test"]`, byKey["server.cors.allowed_origins"].Value) + + // A populated OmitWhenEmpty key must still be emitted, with its real value. + require.Contains(t, byKey, "server.base_url") + assert.Equal(t, "https://api.example.test", byKey["server.base_url"].Value) + + // server.tls_cert_file / server.tls_key_file are special-cased on + // server.tls_enabled (not OmitWhenEmpty) — with TLS enabled they must + // appear. + require.Contains(t, byKey, "server.tls_cert_file") + assert.Equal(t, "/tmp/cert.pem", byKey["server.tls_cert_file"].Value) + require.Contains(t, byKey, "server.tls_key_file") + assert.Equal(t, "/tmp/key.pem", byKey["server.tls_key_file"].Value) +} + +// TestGetMigratableSettings_OmitsEmptyOptionalKeys pins the conditional +// emission this refactor preserves — NOT the "make emission unconditional" +// instruction in the original brief, which the controller's Ruling 16 +// rejected as unsafe (see OmitWhenEmpty's doc comment on SettingDef). +// A zero-value Config must not emit any OmitWhenEmpty key, exactly +// reproducing the pre-registry builders' `if x != ""` / `if len(x) > 0` / +// `if x > 0` guards. +func TestGetMigratableSettings_OmitsEmptyOptionalKeys(t *testing.T) { + c := &Config{} + byKey := map[string]MigratableSetting{} + for _, s := range c.GetMigratableSettings() { + byKey[s.Key] = s + } + + // One representative key per emptiness kind (plain string "", JSON + // "[]"/"null", and numeric "0") plus every OmitWhenEmpty key that also + // belongs to DefaultOperationalSettings() — those are the ones where + // getting this wrong seeds a spurious row (see the test below). + omitWhenEmptyKeys := []string{ + "server.base_url", + "server.cors.allowed_origins", + "database.redis.url", + "auth.oauth_callback_url", + "auth.oauth.client_callback_allowlist", + "operator.name", + "operator.contact", + "operator.jurisdiction", + "secrets.vault_address", + "secrets.vault_path", + "secrets.aws_region", + "secrets.aws_secret_name", + "secrets.azure_vault_url", + "secrets.gcp_project_id", + "secrets.gcp_secret_name", + "secrets.oci_compartment_id", + "secrets.oci_vault_id", + "secrets.oci_secret_name", + "ssrf.issue_uri.allowlist", + "ssrf.issue_uri.schemes", + "ssrf.document_uri.allowlist", + "ssrf.document_uri.schemes", + "ssrf.repository_uri.allowlist", + "ssrf.repository_uri.schemes", + "ssrf.timmy.allowlist", + "ssrf.timmy.schemes", + "ssrf.webhook.allowlist", + "ssrf.webhook.schemes", + "content_oauth.callback_url", + "content_sources.google_drive.service_account_email", + "content_sources.google_drive.credentials_file", + "content_sources.google_drive.browser_oauth_client_id", + "content_sources.google_drive.picker_developer_key", + "content_sources.google_drive.picker_app_id", + "content_sources.google_workspace.picker_developer_key", + "content_sources.google_workspace.picker_app_id", + "content_sources.microsoft.tenant_id", + "content_sources.microsoft.client_id", + "content_sources.microsoft.application_object_id", + "content_sources.microsoft.picker_origin", + "alerting.webhook_url", + "alerting.webhook_secret", + "administrators", + "websocket.inactivity_timeout_seconds", + "session.timeout_minutes", + } + for _, key := range omitWhenEmptyKeys { + _, ok := byKey[key] + assert.False(t, ok, "%s must be omitted from a zero-value Config (OmitWhenEmpty)", key) + } + + // server.tls_cert_file / server.tls_key_file: omitted because + // server.tls_enabled is false on a zero-value Config, not because of + // OmitWhenEmpty (they deliberately don't carry that flag — see the + // comment above their declarations in setting_defs_server.go). + _, ok := byKey["server.tls_cert_file"] + assert.False(t, ok, "server.tls_cert_file must be omitted when server.tls_enabled is false") + _, ok = byKey["server.tls_key_file"] + assert.False(t, ok, "server.tls_key_file must be omitted when server.tls_enabled is false") +} + +// TestGetMigratableSettings_CarriesClassAndSecrecy pins that every emitted +// key's Class and secrecy come from the registry — or, for generated +// provider keys (which have no SettingDef by design), from prefix +// classification. +func TestGetMigratableSettings_CarriesClassAndSecrecy(t *testing.T) { + c := sampleConfig() + for _, s := range c.GetMigratableSettings() { + // Per-provider keys are generated, not statically declared, and have + // no SettingDef by design. Their secrecy lives on the per-setting + // Secret flag rather than on Class.Secret, so comparing them against + // a SettingDef would be both impossible and wrong. + if isGeneratedProviderKey(s.Key) { + assert.True(t, s.Class.Category != CategoryUnclassified, + "generated provider key %s must still be prefix-classified", s.Key) + continue + } + d, ok := DefFor(s.Key) + require.True(t, ok, "emitted key %s has no SettingDef", s.Key) + assert.Equal(t, d.Class.Category, s.Class.Category, "class mismatch for %s", s.Key) + assert.Equal(t, d.IsSecret(), s.IsSecret(), "secrecy mismatch for %s", s.Key) + } +} + +func TestGetMigratableSettings_ExplicitTracksEnvAndFile(t *testing.T) { + t.Setenv("TMI_SERVER_PORT", "9090") + c := sampleConfig() + found := false + for _, s := range c.GetMigratableSettings() { + if s.Key == "server.port" { + found = true + assert.Equal(t, "environment", s.Source) + assert.True(t, s.Explicit, "an env-set key must be Explicit") + } + } + require.True(t, found, "server.port must be emitted by GetMigratableSettings") +} + +// TestDefaultOperationalSettings_MatchesPreRegistryBaseline is the single +// most important test in this file. want is the exact key/value set +// captured from DefaultOperationalSettings() BEFORE this refactor (the +// pre-registry, per-section-builder implementation of GetMigratableSettings, +// commit 382a6fd2), by running a throwaway test and recording its output — +// see the task report for the capture command. +// +// api/settings_service.go's SeedDefaults and #794's origin backfill +// (internal/dbschema/system_setting_origin_backfill.go) both consume +// DefaultOperationalSettings() to decide, respectively, what a fresh +// database seeds into system_settings and whether an existing NULL-origin +// row reads as seeded or explicit. If this set changes — a key appears, +// disappears, or its value changes — a fresh database seeds different rows +// and #794's backfill classifies existing rows differently. This test is +// the gate that would catch that. +func TestDefaultOperationalSettings_MatchesPreRegistryBaseline(t *testing.T) { + want := map[string]string{ + "auth.auto_promote_first_user": "false", + "auth.cookie.domain": "", + "auth.cookie.enabled": "true", + "auth.cookie.secure": "false", + "auth.everyone_is_a_reviewer": "false", + "auth.jwt.expiration_seconds": "3600", + "auth.jwt.refresh_token_days": "7", + "auth.jwt.session_lifetime_days": "7", + "auth.oauth_callback_url": "http://localhost:8080/oauth2/callback", + "auth.step_up_window_seconds": "300", + "content_extractors.compressed_size_bytes": "20971520", + "content_extractors.decompressed_size_bytes": "52428800", + "content_extractors.markdown_size_bytes": "131072", + "content_extractors.part_size_bytes": "20971520", + "content_extractors.per_user_concurrency_default": "2", + "content_extractors.pptx_slides": "100", + "content_extractors.wall_clock_budget": "30s", + "content_extractors.xlsx_cells": "1000", + "content_sources.confluence.enabled": "false", + "content_sources.google_drive.enabled": "false", + "content_sources.google_workspace.enabled": "false", + "content_sources.microsoft.enabled": "false", + "extraction.async_enabled": "false", + "features.saml_enabled": "false", + "observability.enabled": "false", + "observability.prometheus_port": "0", + "observability.sampling_rate": "1", + "server.disable_rate_limiting": "false", + "server.ratelimit_public_rpm": "0", + "server.require_if_match": "false", + "session.timeout_minutes": "60", + "timmy.chunk_overlap": "50", + "timmy.chunk_size": "512", + "timmy.code_embedding_api_key": "", + "timmy.code_embedding_base_url": "", + "timmy.code_embedding_model": "", + "timmy.code_embedding_provider": "", + "timmy.code_retrieval_top_k": "10", + "timmy.dump_extracted_text_to_note": "false", + "timmy.embedding_cleanup_interval_minutes": "60", + "timmy.embedding_dimension": "0", + "timmy.embedding_idle_days_active": "30", + "timmy.embedding_idle_days_closed": "7", + "timmy.enabled": "false", + "timmy.inactivity_timeout_seconds": "3600", + "timmy.llm_api_key": "", + "timmy.llm_base_url": "", + "timmy.llm_max_tokens": "4096", + "timmy.llm_model": "", + "timmy.llm_provider": "", + "timmy.llm_timeout_seconds": "120", + "timmy.max_concurrent_llm_requests": "10", + "timmy.max_conversation_history": "50", + "timmy.max_memory_mb": "256", + "timmy.max_messages_per_user_per_hour": "60", + "timmy.max_sessions_per_threat_model": "50", + "timmy.operator_system_prompt": "", + "timmy.query_decomposition_enabled": "false", + "timmy.rerank_api_key": "", + "timmy.rerank_base_url": "", + "timmy.rerank_model": "", + "timmy.rerank_provider": "", + "timmy.rerank_top_k": "10", + "timmy.text_embedding_api_key": "", + "timmy.text_embedding_base_url": "", + "timmy.text_embedding_model": "", + "timmy.text_embedding_provider": "", + "timmy.text_retrieval_top_k": "10", + "webhooks.allow_http_targets": "false", + "websocket.inactivity_timeout_seconds": "300", + } + + got := DefaultOperationalSettings() + gotMap := make(map[string]string, len(got)) + for _, s := range got { + gotMap[s.Key] = s.Value + } + + assert.Equal(t, want, gotMap, + "DefaultOperationalSettings() must match the pre-registry baseline exactly — "+ + "a diff here means SeedDefaults would seed a different row set on a fresh "+ + "database, or #794's origin backfill would classify existing rows differently") + assert.Len(t, want, 70, "baseline has a fixed size; update deliberately, not by drift") +} diff --git a/internal/config/reference_gen.go b/internal/config/reference_gen.go index 07c53df2..18f7c909 100644 --- a/internal/config/reference_gen.go +++ b/internal/config/reference_gen.go @@ -12,7 +12,7 @@ import ( // tables — bootstrap and operational — so the wiki Configuration-Reference // page is generated, not hand-maintained. Secret defaults are shown as // vault:// placeholders, never real values. -// SEM@0000000000000000000000000000000000000000: build the wiki configuration reference as Markdown with a precedence explainer and bootstrap/operational tables (pure) +// SEM@e6cee63c3a07d38f471e0ebfb81722849f36085e: build the wiki configuration reference as Markdown with a precedence explainer and bootstrap/operational tables (pure) func GenerateReferenceMarkdown() ([]byte, error) { cfg := getDefaultConfig() cfg.Server.TLSSubjectName = "localhost" // deterministic — must not embed the build host's name @@ -49,9 +49,12 @@ func GenerateReferenceMarkdown() ([]byte, error) { } b.WriteString("\n## Operational settings\n\n") - b.WriteString("DB-backed, seeded from defaults on first run, editable at runtime via `/admin/settings`.\n\n") - b.WriteString("| Key | Type | Default | Mutability | Visibility | Secret | Precedence | Description |\n") - b.WriteString("|-----|------|---------|------------|------------|--------|------------|-------------|\n") + b.WriteString("DB-backed, seeded from defaults on first run, editable at runtime via `/admin/settings`. " + + "A setting with an env var is Transitional: still config/env-eligible during migration " + + "(see [[Configuration-Model]]) — most operational settings have no env var at all, since they " + + "exist only in the database.\n\n") + b.WriteString("| Key | Env var | Type | Default | Mutability | Visibility | Secret | Precedence | Description |\n") + b.WriteString("|-----|---------|------|---------|------------|------------|--------|------------|-------------|\n") for _, s := range operational { b.WriteString(operationalRow(s)) } @@ -63,7 +66,7 @@ func GenerateReferenceMarkdown() ([]byte, error) { // the system_settings database row) wins for a given key, placed above the // tables so an operator debugging a config problem doesn't have to read // source to find the rule. See #794. -// SEM@0000000000000000000000000000000000000000: render the config/env-vs-database precedence rule as a Markdown section (pure) +// SEM@05517d8cb7bfbe65374f23c29bbc9bd51efe97e2: render the config/env-vs-database precedence rule as a Markdown section (pure) func precedenceSection() string { return "## Precedence\n\n" + "A setting's value can come from a YAML config file, an environment " + @@ -97,23 +100,23 @@ func precedenceSection() string { "contributes any IDs that config does not define.\n\n" } -// SEM@0000000000000000000000000000000000000000: format a bootstrap config setting as a Markdown table row (pure) +// SEM@05517d8cb7bfbe65374f23c29bbc9bd51efe97e2: format a bootstrap config setting as a Markdown table row (pure) func bootstrapRow(s MigratableSetting) string { return fmt.Sprintf("| `%s` | %s | %s | %s | %s | %s | %s | %s |\n", s.Key, codeOrDash(s.EnvVar), s.Type, defaultCell(s), yesNo(s.Class.Required), yesNo(s.Class.Secret), precedenceCell(s), sanitizeCell(s.Description)) } -// SEM@0000000000000000000000000000000000000000: format an operational config setting as a Markdown table row (pure) +// SEM@e6cee63c3a07d38f471e0ebfb81722849f36085e: format an operational config setting as a Markdown table row (pure) func operationalRow(s MigratableSetting) string { - return fmt.Sprintf("| `%s` | %s | %s | %s | %s | %s | %s | %s |\n", - s.Key, s.Type, defaultCell(s), s.Class.Mutability.String(), + return fmt.Sprintf("| `%s` | %s | %s | %s | %s | %s | %s | %s | %s |\n", + s.Key, codeOrDash(s.EnvVar), s.Type, defaultCell(s), s.Class.Mutability.String(), s.Class.Visibility.String(), yesNo(s.Class.Secret), precedenceCell(s), sanitizeCell(s.Description)) } // precedenceCell renders the terse per-row precedence summary; the full rule // is spelled out once in precedenceSection rather than repeated per row. -// SEM@0000000000000000000000000000000000000000: summarize which source wins for a setting's category as a table cell (pure) +// SEM@05517d8cb7bfbe65374f23c29bbc9bd51efe97e2: summarize which source wins for a setting's category as a table cell (pure) func precedenceCell(s MigratableSetting) string { if s.Class.Category == CategoryBootstrap { return "config/env only" diff --git a/internal/config/reference_gen_test.go b/internal/config/reference_gen_test.go index a07d48f3..f7bcbd57 100644 --- a/internal/config/reference_gen_test.go +++ b/internal/config/reference_gen_test.go @@ -104,6 +104,39 @@ func findRowContaining(t *testing.T, doc, keyCell string) string { return "" } +// TestGenerateReferenceMarkdown_CoversEveryEmittedEnvVar is the allowlist- +// completeness guardrail: the config reference doc doubles as this project's +// TMI_* environment-variable allowlist, so any env var the generators +// actually emit a setting for must be named somewhere in the generated +// reference — an emitted env var missing from the doc would silently narrow +// the allowlist. This checks what GetMigratableSettings() (what the +// generators consume) actually emits, not the full registry: a setting the +// registry declares but the generators never surface (e.g. because its +// value is empty and OmitWhenEmpty) is a separate, pre-existing gap, not +// something this guardrail is meant to catch. +func TestGenerateReferenceMarkdown_CoversEveryEmittedEnvVar(t *testing.T) { + out, err := GenerateReferenceMarkdown() + if err != nil { + t.Fatalf("GenerateReferenceMarkdown: %v", err) + } + s := string(out) + + cfg := getDefaultConfig() + cfg.Server.TLSSubjectName = "localhost" + var missing []string + for _, ms := range cfg.GetMigratableSettings() { + if ms.EnvVar == "" { + continue + } + if !strings.Contains(s, ms.EnvVar) { + missing = append(missing, ms.EnvVar) + } + } + if len(missing) > 0 { + t.Errorf("generated reference is missing emitted env vars (it doubles as the TMI_* allowlist): %v", missing) + } +} + func TestConfigReferenceFile_MatchesRegistry(t *testing.T) { generated, err := GenerateReferenceMarkdown() if err != nil { diff --git a/internal/config/registry_coverage_test.go b/internal/config/registry_coverage_test.go new file mode 100644 index 00000000..3346e77b --- /dev/null +++ b/internal/config/registry_coverage_test.go @@ -0,0 +1,166 @@ +package config + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRegistry_CoversEveryReachableKey asserts that every setting key the +// server can reach — from the Config struct, the classification registry, or +// the database seed list — has a SettingDef with a real Category. +// +// The previous guardrail could not do this: ValidateClassifications took +// whatever slice it was handed, in practice GetMigratableSettings(), which by +// construction contains only keys that already have a Config struct field, +// and which emits conditionally so the set changed with runtime values. +// +// Of the three reachable sources checked here, only two can actually fail +// this test: the Config struct env-tag source (envTaggedKeys) and the +// classification registry source (exactClassifications) are independent of +// AllSettingDefs() and so can genuinely surface an undeclared key. The +// database seed list source (SeedableOperationalDefs()) cannot: it is itself +// derived from AllSettingDefs(), so every key it yields is declared by +// construction and this leg is tautological. It stays here for readability — +// removing it wouldn't change what this test can catch — but the invariant +// that actually matters for the seed list (that it names the *right* keys, +// not just declared ones) is pinned elsewhere: by name, in +// TestSeedableOperationalDefs_MatchesGoldenList; over the real resolution +// path, in TestClassificationFor_SeededKeysAreNotInternal; and structurally, +// by the four Seeded validation rules. +func TestRegistry_CoversEveryReachableKey(t *testing.T) { + // declared covers a key two ways: by its SettingDef.Key (the common + // case), and by its SettingDef.YAMLPath (for the handful of defs whose + // canonical settings Key deliberately differs from the raw Config + // struct path — e.g. "auth.saml.enabled" is declared as + // "features.saml_enabled" with YAMLPath: "auth.saml.enabled"). The + // env-tag source below yields raw struct yaml paths, not settings keys, + // so a rename would otherwise look uncovered despite being fully + // declared. This can't be gamed with a fabricated YAMLPath: + // TestSettingDefs_YAMLPathsAreReal (setting_defs_bijection_test.go) + // already asserts every non-empty YAMLPath names a real yaml-tagged + // Config field. + declared := map[string]bool{} + for _, d := range AllSettingDefs() { + declared[d.Key] = true + if d.YAMLPath != "" { + declared[d.YAMLPath] = true + } + } + + reachable := map[string]string{} // key -> where it came from + + for k := range envTaggedKeys(t) { + reachable[k] = "Config struct env tag" + } + for k := range exactClassifications { + reachable[k] = "classification registry" + } + for _, d := range SeedableOperationalDefs() { + reachable[d.Key] = "database seed list" + } + + var uncovered []string + for k, src := range reachable { + if !declared[k] { + uncovered = append(uncovered, k+" (from "+src+")") + } + } + sort.Strings(uncovered) + + assert.Empty(t, uncovered, + "every reachable setting key must have a SettingDef; add one to internal/config/setting_defs_*.go") +} + +func TestRegistry_NoKeyIsDeclaredTwice(t *testing.T) { + seen := map[string]int{} + for _, d := range AllSettingDefs() { + seen[d.Key]++ + } + var dupes []string + for k, n := range seen { + if n > 1 { + dupes = append(dupes, k) + } + } + sort.Strings(dupes) + assert.Empty(t, dupes, "a setting must be declared exactly once") +} + +func TestRegistry_EveryDefHasANonZeroCategory(t *testing.T) { + for _, d := range AllSettingDefs() { + assert.NotEqual(t, CategoryUnclassified, d.Class.Category, + "%s has the zero Category", d.Key) + } +} + +func TestRegistry_PassesValidation(t *testing.T) { + assert.NoError(t, ValidateSettingDefs(AllSettingDefs())) +} + +// seededKeys is the exact set of settings written into system_settings at +// database initialisation. Changing this list changes what every NEW database +// gets seeded with, so it is pinned deliberately rather than derived. +var seededKeys = []string{ + "features.saml_enabled", + "features.webhooks_enabled", + "features.websocket_enabled", + "rate_limit.requests_per_hour", + "rate_limit.requests_per_minute", + "session.timeout_minutes", + "ui.default_theme", + "upload.max_file_size_mb", + "websocket.max_participants", +} + +// TestSeedableOperationalDefs_MatchesGoldenList pins SeedableOperationalDefs +// to the exact golden list above. Nothing else stops someone flipping +// Seeded: true on another operational def and silently adding a seed row to +// every new database; this test is that stop. SeedableOperationalDefs is +// already sorted by key, so seededKeys is compared verbatim rather than +// re-sorted — keeping it alphabetized here doubles as documentation. +func TestSeedableOperationalDefs_MatchesGoldenList(t *testing.T) { + var got []string + for _, d := range SeedableOperationalDefs() { + got = append(got, d.Key) + } + + assert.Equal(t, seededKeys, got, + "the set of seeded keys changed — this list is deliberately pinned by name "+ + "(not derived from Class) since it decides what every NEW database gets "+ + "seeded with; update seededKeys above only if that is an intended change") +} + +// TestSettingDefs_OnlySessionTimeoutHasEmptyYAMLPathWithGet closes the +// falsely-empty-YAMLPath hole: a def with a non-nil Get but an empty +// YAMLPath escapes the bijection test, the env-var match test, and +// validation (which makes YAMLPath optional for transitional defs). Only +// session.timeout_minutes is legitimate here — it is genuinely derived +// (computed from auth.jwt.expiration_seconds, no struct field of its own). +// Defs with a nil Get (database-only settings, e.g. the DB-only +// client-config knobs) legitimately have an empty YAMLPath too and are +// excluded from this check. +func TestSettingDefs_OnlySessionTimeoutHasEmptyYAMLPathWithGet(t *testing.T) { + var offenders []string + for _, d := range AllSettingDefs() { + if d.Get == nil { + continue // database-only setting: no config-file path to speak of + } + if d.YAMLPath == "" && d.Key != "session.timeout_minutes" { + offenders = append(offenders, d.Key) + } + } + sort.Strings(offenders) + assert.Empty(t, offenders, + "only session.timeout_minutes may have a non-nil Get with an empty YAMLPath "+ + "(it is derived from auth.jwt.expiration_seconds, with no struct field of "+ + "its own); an empty YAMLPath on any other def with a Get usually means a "+ + "real config path was blanked out") + + d, ok := DefFor("session.timeout_minutes") + require.True(t, ok, "session.timeout_minutes must be declared") + assert.NotNil(t, d.Get, "session.timeout_minutes must still have a Get accessor") + assert.Empty(t, d.YAMLPath, "session.timeout_minutes has no struct field of its own") +} diff --git a/internal/config/seed_projection.go b/internal/config/seed_projection.go new file mode 100644 index 00000000..e1acbea1 --- /dev/null +++ b/internal/config/seed_projection.go @@ -0,0 +1,27 @@ +package config + +import "sort" + +// SeedableOperationalDefs returns the operational settings that should be +// seeded into system_settings on database initialisation, sorted by key. +// +// This is the single source for the seed list. DefaultSystemSettings() in +// api/models projects it, rather than maintaining a parallel list — the +// parallel list is what left rate_limit.requests_per_minute and +// rate_limit.requests_per_hour seeded but unclassified (#809). +// +// Filtering on Seeded, not just CategoryOperational, is deliberate: the +// registry holds far more operational defs (config/env-delivered knobs like +// websocket.inactivity_timeout_seconds) than are ever written into +// system_settings. Seeded marks exactly the subset that is. +// SEM@62e82fc4e96a1c18f4e1ac1d698de4fefb974b42: list operational settings that should be seeded into the database (pure) +func SeedableOperationalDefs() []SettingDef { + var out []SettingDef + for _, d := range settingDefs { + if d.Class.Category == CategoryOperational && d.Seeded { + out = append(out, d) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + return out +} diff --git a/internal/config/seed_projection_test.go b/internal/config/seed_projection_test.go new file mode 100644 index 00000000..510482e1 --- /dev/null +++ b/internal/config/seed_projection_test.go @@ -0,0 +1,59 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSeedableOperationalDefs_IncludesPreviouslyUnclassifiedKeys(t *testing.T) { + // #809: these two were seeded into system_settings but had no + // classification entry, so they resolved to VisibilityInternal and 404'd + // on GET/DELETE while appearing in the LIST response. + for _, key := range []string{ + "rate_limit.requests_per_minute", + "rate_limit.requests_per_hour", + } { + d, ok := DefFor(key) + require.True(t, ok, "%s must be declared", key) + assert.Equal(t, CategoryOperational, d.Class.Category) + assert.Equal(t, VisibilityAdminOnly, d.Class.Visibility, + "%s is not consumed by tmi-ux, so admin-only rather than public", key) + assert.False(t, d.Transitional, "%s has no config path", key) + } +} + +func TestSeedableOperationalDefs_AreSortedAndNonEmpty(t *testing.T) { + defs := SeedableOperationalDefs() + require.NotEmpty(t, defs) + for i := 1; i < len(defs); i++ { + assert.Less(t, defs[i-1].Key, defs[i].Key, "SeedableOperationalDefs must be sorted by key") + } + for _, d := range defs { + assert.Equal(t, CategoryOperational, d.Class.Category) + assert.NotEmpty(t, d.Default) + } +} + +// TestClassificationFor_SeededKeysAreNotInternal is the regression test for +// #809 round 1: a SettingDef.Class populated correctly is not enough on its +// own, because api/config_handlers.go's GET/DELETE /admin/settings/{key} +// checks config.ClassificationFor(key).Visibility, which resolves through +// classification_registry.go's exactClassifications/prefixClassifications — +// a separate table from the SettingDef registry until Phase E converges +// them. A key can pass every SettingDef-level assertion above and still +// 404 at the actual endpoint if it has no exactClassifications entry. This +// test ties every seeded key to that real runtime resolution path, so it +// would have caught rate_limit.* still 404ing after round 1's declaration +// went in. +func TestClassificationFor_SeededKeysAreNotInternal(t *testing.T) { + for _, d := range SeedableOperationalDefs() { + cls := ClassificationFor(d.Key) + assert.NotEqual(t, VisibilityInternal, cls.Visibility, + "%s is seeded into system_settings and shown by GET /admin/settings, "+ + "so ClassificationFor must not resolve it to VisibilityInternal — "+ + "that is what makes GET/DELETE /admin/settings/{key} return 404 (#809)", d.Key) + assert.NotEqual(t, CategoryUnclassified, cls.Category, "%s must be classified", d.Key) + } +} diff --git a/internal/config/setting_def.go b/internal/config/setting_def.go new file mode 100644 index 00000000..1d8a9f74 --- /dev/null +++ b/internal/config/setting_def.go @@ -0,0 +1,137 @@ +package config + +// SettingDef is the single authoritative declaration of one configuration +// setting. Every setting the server can read — bootstrap or operational, +// config-delivered or database-seeded — has exactly one SettingDef. +// +// Which fields are legal depends on Class.Category; ValidateSettingDefs +// enforces that (see setting_def_validation.go). +// SEM@71b4a22251f6acd8a9fb37257d5938c174392b09: declare a single configuration setting's type, class, default, and delivery path +type SettingDef struct { + // Key is the canonical dotted setting key, e.g. "server.port". + Key string + // Class is the full classification: category, visibility, secrecy, + // mutability, consumers, delivery. + Class ConfigClass + // Type is the value's canonical type: "string", "bool", "int", + // "float", or "json". + Type string + // Description is human-readable and required; it feeds generated docs + // and the admin API. + Description string + // Default is the canonical string form of the compiled-in default. + // Required for operational settings, which have no config file to + // fall back to. + Default string + + // YAMLPath, EnvVar and Get are populated only for settings currently + // delivered by config file or environment: all bootstrap settings, plus + // operational settings still in transition (see Transitional). + YAMLPath string + EnvVar string + // Get extracts this setting's current value from a loaded Config as a + // canonical string. Nil for settings with no config-file path. + Get func(*Config) string + + // Transitional marks an operational setting that still has a config/env + // delivery path during the Phase A-E cutover described in the spec. + // Every Transitional entry is scheduled for removal in Phase E; the + // ratchet test in Task 9 prevents new ones from being added. + Transitional bool + + // Seeded marks an operational setting that is written into the + // system_settings table on database initialisation. It is deliberately + // explicit rather than inferred: the set of seeded keys does not + // coincide with any other property of a setting, and inferring it wrong + // would either seed rows nobody set or stop seeding rows the server + // expects. Only operational settings may be Seeded. + Seeded bool + + // OmitWhenEmpty reproduces the conditional emission of the pre-registry + // builders: this setting is left out of GetMigratableSettings() entirely + // when its value is empty, rather than emitted with an empty value. + // + // It is load-bearing, not cosmetic. DefaultOperationalSettings() feeds + // both SeedDefaults (which would otherwise write a row per omitted key on + // every fresh database) and #794's origin backfill (which uses the same + // set to decide seeded-vs-explicit, and therefore env-vs-database + // precedence on existing databases). + // + // Phase E removes this along with the rest of the transitional machinery. + OmitWhenEmpty bool +} + +// IsSecret reports whether this setting's value must never appear in an API +// response or a log line. +// +// This answers the question only for STATICALLY DECLARED settings. The +// per-provider keys under auth.oauth.providers., auth.saml.providers. and +// content_oauth.providers. are generated per configured provider and have no +// SettingDef; for those, Class.Secret is deliberately false (a blanket true +// would mis-mask non-secret sub-keys like .client_id) and secrecy is carried +// only by the per-setting Secret flag that the provider helpers set on +// .client_secret, .sp_private_key and .idp_metadata_b64xml. Never reach for +// this method to decide secrecy for a provider key — use +// MigratableSetting.IsSecret(), which ORs both flags. +// SEM@51cab5c0b8755f522c896d5e22ac0a1eb206031f: report whether a statically declared setting's value must be masked (pure) +func (d SettingDef) IsSecret() bool { + return d.Class.Secret +} + +// settingDefs is the authoritative registry, assembled from every +// setting_defs_*.go declaration file. +var settingDefs = concatDefs(serverSettingDefs, authSettingDefs, contentSettingDefs, miscSettingDefs) + +// settingDefIndex is the by-key lookup, built once from settingDefs. +var settingDefIndex = indexDefs(settingDefs) + +// concatDefs joins definition groups into the single registry slice. +// SEM@a7b9afd2035408abf3582c0c7e701589a42edac8: join setting definition groups into the registry slice (pure) +func concatDefs(groups ...[]SettingDef) []SettingDef { + var out []SettingDef + for _, g := range groups { + out = append(out, g...) + } + return out +} + +// withMutability returns a copy of c with Mutability overridden. Most +// operational SettingDefs take their ConfigClass verbatim from +// classificationFor/operationalClass, whose Mutability default (Hot) is +// correct for the common case: a setting re-read through the settings +// service at use time. withMutability is the explicit, per-entry override +// for the settings that are instead captured once at server construction — +// a database edit to one of these does not take effect without a restart. +// Every call site names the specific consuming code that makes it so; see +// setting_defs_server.go, setting_defs_auth.go, setting_defs_content.go and +// setting_defs_misc.go for the individual justifications. +// SEM@0ef42e85753d183c8b8238027d47aebabfabd8e9: override a setting classification's mutability for a captured-at-startup setting (pure) +func withMutability(c ConfigClass, m Mutability) ConfigClass { + c.Mutability = m + return c +} + +// indexDefs builds a by-key lookup map from a slice of definitions. +// SEM@51cab5c0b8755f522c896d5e22ac0a1eb206031f: build a by-key lookup map from setting definitions (pure) +func indexDefs(defs []SettingDef) map[string]SettingDef { + idx := make(map[string]SettingDef, len(defs)) + for _, d := range defs { + idx[d.Key] = d + } + return idx +} + +// DefFor returns the declaration for a setting key, and whether one exists. +// SEM@51cab5c0b8755f522c896d5e22ac0a1eb206031f: fetch a setting's registry declaration by key (pure) +func DefFor(key string) (SettingDef, bool) { + d, ok := settingDefIndex[key] + return d, ok +} + +// AllSettingDefs returns a copy of the registry, so callers cannot mutate it. +// SEM@51cab5c0b8755f522c896d5e22ac0a1eb206031f: list a defensive copy of the setting definition registry (pure) +func AllSettingDefs() []SettingDef { + out := make([]SettingDef, len(settingDefs)) + copy(out, settingDefs) + return out +} diff --git a/internal/config/setting_def_test.go b/internal/config/setting_def_test.go new file mode 100644 index 00000000..cc43c568 --- /dev/null +++ b/internal/config/setting_def_test.go @@ -0,0 +1,48 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefFor_ReturnsRegisteredDef(t *testing.T) { + defs := []SettingDef{ + { + Key: "test.example", + Type: "string", + Description: "an example", + Class: ConfigClass{ + Category: CategoryBootstrap, + Visibility: VisibilityInternal, + Consumers: []Consumer{ConsumerMonolith}, + }, + YAMLPath: "test.example", + EnvVar: "TMI_TEST_EXAMPLE", + Get: func(c *Config) string { return "v" }, + }, + } + idx := indexDefs(defs) + + got, ok := idx["test.example"] + require.True(t, ok) + assert.Equal(t, "string", got.Type) + assert.Equal(t, CategoryBootstrap, got.Class.Category) +} + +func TestDefFor_UnknownKeyReturnsFalse(t *testing.T) { + _, ok := DefFor("no.such.key.anywhere") + assert.False(t, ok) +} + +func TestAllSettingDefs_ReturnsACopy(t *testing.T) { + a := AllSettingDefs() + require.NotNil(t, a) + if len(a) == 0 { + t.Skip("registry not yet populated; covered from Task 3 onward") + } + a[0].Key = "mutated" + b := AllSettingDefs() + assert.NotEqual(t, "mutated", b[0].Key, "AllSettingDefs must not expose the backing array") +} diff --git a/internal/config/setting_def_validation.go b/internal/config/setting_def_validation.go new file mode 100644 index 00000000..a2544b01 --- /dev/null +++ b/internal/config/setting_def_validation.go @@ -0,0 +1,188 @@ +package config + +import ( + "fmt" + "strings" +) + +// validSettingTypes is the closed set of canonical value types. +var validSettingTypes = map[string]bool{ + "string": true, + "bool": true, + "int": true, + "float": true, + "json": true, +} + +// ValidateSettingDefs checks the registry's internal consistency and returns +// an error naming every problem found, one per line, or nil. It is the +// enforcement point for the rule that every setting must be classified: a +// definition whose Category is the zero value is rejected. +// +// This supersedes ValidateClassifications for the SettingDef registry: every +// rule that function enforces on a MigratableSetting is carried across here, +// plus the additional legality rules that apply to SettingDef's config-path +// (YAMLPath/EnvVar/Get) and Seeded fields. +// SEM@90fd6060aa317cecb2310cc1bcb5092066f872ae: validate the setting registry's internal consistency and return all problems (pure) +func ValidateSettingDefs(defs []SettingDef) error { + var problems []string + add := func(key, msg string) { + problems = append(problems, fmt.Sprintf("%s: %s", key, msg)) + } + + seen := make(map[string]bool, len(defs)) + for _, d := range defs { + if d.Key == "" { + problems = append(problems, "(empty key): a definition has no Key") + continue + } + if seen[d.Key] { + add(d.Key, "duplicate key in registry") + continue + } + seen[d.Key] = true + + if d.Class.Category == CategoryUnclassified { + add(d.Key, "unclassified — Category is the zero value") + continue + } + + validateGeneralRules(d, add) + + switch d.Class.Category { + case CategoryBootstrap: + validateBootstrapRules(d, add) + case CategoryOperational: + validateOperationalRules(d, add) + default: + add(d.Key, fmt.Sprintf("unknown Category value %d — update ValidateSettingDefs", d.Class.Category)) + } + } + + if len(problems) == 0 { + return nil + } + return fmt.Errorf("invalid setting definitions:\n %s", strings.Join(problems, "\n ")) +} + +// validateGeneralRules checks the rules that apply regardless of category. +// SEM@58a7f5ed65e755654da55d388db45d37f7d55d76: validate the rules that apply to every setting definition regardless of category (pure) +func validateGeneralRules(d SettingDef, add func(key, msg string)) { + c := d.Class + + if d.Description == "" { + add(d.Key, "empty Description") + } + if len(c.Consumers) == 0 { + add(d.Key, "no Consumers declared") + } + if !validSettingTypes[d.Type] { + add(d.Key, fmt.Sprintf("unknown type %q", d.Type)) + } + if c.ValueKind == ValueKindReference && !c.Secret { + add(d.Key, "a reference value kind is only valid on a Secret setting") + } + if c.Visibility == VisibilityPublic && c.Secret { + add(d.Key, "a public setting must not be Secret") + } + if c.Required && c.Category != CategoryBootstrap { + add(d.Key, "Required implies bootstrap") + } + if d.Seeded && c.Category != CategoryOperational { + add(d.Key, "Seeded implies CategoryOperational — a bootstrap setting must never be Seeded") + } + // Only Seeded keys are ever written into system_settings at database + // init (models.DefaultSystemSettings), so Default is load-bearing + // specifically for them — this is narrower than, and independent of, the + // general "operational setting must declare a Default" rule below, which + // exempts Type "string" and Class.Secret. A Seeded setting must still + // have something to seed regardless of Type or Secret. + if d.Seeded && d.Default == "" { + add(d.Key, "Seeded setting must declare a Default — it is written into system_settings at database init") + } + // A secret must never be seeded into system_settings with a compiled-in + // value: that would either be an empty placeholder (useless) or a real + // hardcoded credential (never acceptable) landing in the database at + // every install. + if d.Seeded && c.Secret { + add(d.Key, "Seeded setting must not be Secret — a secret must never be seeded into system_settings with a compiled-in value") + } + // A seeded row is written into system_settings and mergeSettingsWithConfig's + // database-only branch lists it from GET /admin/settings with no + // visibility filter, while GET/DELETE /admin/settings/{key} both 404 on + // VisibilityInternal. VisibilityInternal on a Seeded def recreates #809: + // a key the list endpoint shows but the single-item endpoints refuse. + if d.Seeded && c.Visibility == VisibilityInternal { + add(d.Key, "Seeded setting must not be VisibilityInternal — it is written into system_settings and listed by GET /admin/settings, so an internal visibility makes GET/DELETE /admin/settings/{key} 404 for a key the list endpoint shows (#809)") + } +} + +// validateBootstrapRules checks the rules specific to CategoryBootstrap. +// SEM@90fd6060aa317cecb2310cc1bcb5092066f872ae: validate the rules specific to bootstrap setting definitions (pure) +func validateBootstrapRules(d SettingDef, add func(key, msg string)) { + if d.Class.Delivery != nil { + add(d.Key, "bootstrap setting must not carry a Delivery") + } + if d.Transitional { + add(d.Key, "bootstrap setting must not be Transitional") + } + if d.YAMLPath == "" { + add(d.Key, "bootstrap setting must declare a YAMLPath") + } + if d.EnvVar == "" { + add(d.Key, "bootstrap setting must declare an EnvVar") + } + if d.Get == nil { + add(d.Key, "bootstrap setting must declare a Get accessor") + } +} + +// validateOperationalRules checks the rules specific to CategoryOperational. +// SEM@a7b9afd2035408abf3582c0c7e701589a42edac8: validate the rules specific to operational setting definitions (pure) +func validateOperationalRules(d SettingDef, add func(key, msg string)) { + c := d.Class + + if c.Delivery == nil { + add(d.Key, "operational setting must carry a Delivery") + } + // A secret with no compiled-in default is correct: the alternatives are an + // empty string this rule would otherwise reject, or a hardcoded + // credential, which is never acceptable. + // + // A string-typed setting is exempted the same way for a different reason: + // Default is carried as a plain string, so for Type "string" there is no + // way to distinguish "the compiled-in default genuinely is empty" + // (auth.cookie.domain, every unconfigured Timmy/content-source provider + // field, the fail-closed ssrf.*.allowlist/schemes keys) from "nobody + // filled this in". Every other type's zero value serializes to a + // non-empty string ("false", "0", "[]"), so the check stays meaningful + // there. + if d.Default == "" && !c.Secret && d.Type != "string" { + add(d.Key, "operational setting must declare a Default") + } + if c.Delivery != nil && c.Delivery.SharedInvariant { + if !c.Delivery.StampedIntoEnvelope { + add(d.Key, "SharedInvariant requires StampedIntoEnvelope") + } + if !hasWorkerConsumer(c.Consumers) { + add(d.Key, "SharedInvariant requires at least one worker Consumer") + } + if !hasConsumer(c.Consumers, ConsumerMonolith) { + add(d.Key, "SharedInvariant requires the monolith as a Consumer") + } + } + + hasConfigPath := d.YAMLPath != "" || d.EnvVar != "" || d.Get != nil + if d.Transitional { + // YAMLPath and EnvVar are each independently optional: administrators + // has no env: tag at all (config.go assembles it imperatively in + // Load()), and the derived session.timeout_minutes key has no + // YAMLPath of its own. Get is the one thing every transitional + // setting must still retain until Phase E removes it. + if d.Get == nil { + add(d.Key, "transitional operational setting must declare a Get accessor") + } + } else if hasConfigPath { + add(d.Key, "non-transitional operational setting must have no YAMLPath, EnvVar or Get") + } +} diff --git a/internal/config/setting_def_validation_test.go b/internal/config/setting_def_validation_test.go new file mode 100644 index 00000000..aa74fe15 --- /dev/null +++ b/internal/config/setting_def_validation_test.go @@ -0,0 +1,339 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func validBootstrapDef() SettingDef { + return SettingDef{ + Key: "server.port", + Type: "string", + Description: "HTTP server port", + Class: ConfigClass{ + Category: CategoryBootstrap, + Visibility: VisibilityInternal, + Mutability: MutabilityStatic, + Consumers: []Consumer{ConsumerMonolith}, + }, + YAMLPath: "server.port", + EnvVar: "TMI_SERVER_PORT", + Get: func(c *Config) string { return c.Server.Port }, + } +} + +func validOperationalDef() SettingDef { + return SettingDef{ + Key: "ui.default_theme", + Type: "string", + Description: "Default UI theme", + Default: "auto", + Class: ConfigClass{ + Category: CategoryOperational, + Visibility: VisibilityPublic, + Mutability: MutabilityHot, + Delivery: &Delivery{}, + Consumers: []Consumer{ConsumerMonolith, ConsumerTMIUX}, + }, + } +} + +func TestValidateSettingDefs_AcceptsValidSet(t *testing.T) { + err := ValidateSettingDefs([]SettingDef{validBootstrapDef(), validOperationalDef()}) + assert.NoError(t, err) +} + +func TestValidateSettingDefs_RejectsUnclassified(t *testing.T) { + d := validBootstrapDef() + d.Class.Category = CategoryUnclassified + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unclassified") +} + +func TestValidateSettingDefs_RejectsDuplicateKeys(t *testing.T) { + err := ValidateSettingDefs([]SettingDef{validBootstrapDef(), validBootstrapDef()}) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate") +} + +func TestValidateSettingDefs_BootstrapRequiresEnvVarAndYAMLPath(t *testing.T) { + d := validBootstrapDef() + d.EnvVar = "" + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "EnvVar") + + d = validBootstrapDef() + d.YAMLPath = "" + err = ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "YAMLPath") +} + +func TestValidateSettingDefs_BootstrapRequiresGetter(t *testing.T) { + d := validBootstrapDef() + d.Get = nil + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Get") +} + +func TestValidateSettingDefs_BootstrapMustNotCarryDelivery(t *testing.T) { + d := validBootstrapDef() + d.Class.Delivery = &Delivery{} + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Delivery") +} + +func TestValidateSettingDefs_BootstrapMustNotBeTransitional(t *testing.T) { + d := validBootstrapDef() + d.Transitional = true + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Transitional") +} + +func TestValidateSettingDefs_OperationalRequiresDefault(t *testing.T) { + // Type "bool" here, not the "string" of validOperationalDef(): a + // non-string zero value always serializes to a non-empty string + // ("false"), so an empty Default on a bool setting is unambiguously a + // missing declaration, not a legitimate zero value. See + // TestValidateSettingDefs_StringOperationalDefaultMayBeEmpty for the + // string-typed case, which this rule deliberately does not flag. + d := validOperationalDef() + d.Type = "bool" + d.Default = "" + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Default") +} + +func TestValidateSettingDefs_StringOperationalDefaultMayBeEmpty(t *testing.T) { + // A string-typed setting's Default is exempt from the "must declare" + // rule: Default is carried as a plain string, so there is no way to + // distinguish a genuinely empty compiled-in default (e.g. + // auth.cookie.domain) from one nobody filled in. + d := validOperationalDef() + d.Type = "string" + d.Default = "" + assert.NoError(t, ValidateSettingDefs([]SettingDef{d})) +} + +func TestValidateSettingDefs_OperationalRequiresDelivery(t *testing.T) { + d := validOperationalDef() + d.Class.Delivery = nil + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Delivery") +} + +func TestValidateSettingDefs_NonTransitionalOperationalMustHaveNoConfigPath(t *testing.T) { + d := validOperationalDef() + d.EnvVar = "TMI_UI_DEFAULT_THEME" + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "non-transitional") +} + +func TestValidateSettingDefs_TransitionalOperationalRequiresConfigPath(t *testing.T) { + d := validOperationalDef() + d.Transitional = true + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "transitional") +} + +func TestValidateSettingDefs_TransitionalOperationalAcceptsConfigPath(t *testing.T) { + d := validOperationalDef() + d.Transitional = true + d.EnvVar = "TMI_UI_DEFAULT_THEME" + d.YAMLPath = "ui.default_theme" + d.Get = func(c *Config) string { return "auto" } + assert.NoError(t, ValidateSettingDefs([]SettingDef{d})) +} + +func TestValidateSettingDefs_RejectsUnknownType(t *testing.T) { + d := validBootstrapDef() + d.Type = "widget" + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "type") +} + +func TestValidateSettingDefs_RequiresDescriptionAndConsumers(t *testing.T) { + d := validBootstrapDef() + d.Description = "" + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Description") + + d = validBootstrapDef() + d.Class.Consumers = nil + err = ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Consumers") +} + +func TestValidateSettingDefs_PublicCannotBeSecret(t *testing.T) { + d := validOperationalDef() + d.Class.Secret = true + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "public") +} + +func TestValidateSettingDefs_RequiredImpliesBootstrap(t *testing.T) { + d := validOperationalDef() + d.Class.Required = true + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Required") +} + +func TestValidateSettingDefs_SharedInvariantImpliesStamped(t *testing.T) { + d := validOperationalDef() + d.Class.Delivery = &Delivery{SharedInvariant: true} + d.Class.Consumers = []Consumer{ConsumerMonolith, ConsumerWorkerChunkEmbed} + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "StampedIntoEnvelope") +} + +func TestValidateSettingDefs_SharedInvariantRequiresWorkerConsumer(t *testing.T) { + d := validOperationalDef() + d.Class.Delivery = &Delivery{StampedIntoEnvelope: true, SharedInvariant: true} + d.Class.Consumers = []Consumer{ConsumerMonolith} + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "worker") +} + +func TestValidateSettingDefs_SharedInvariantRequiresMonolithConsumer(t *testing.T) { + d := validOperationalDef() + d.Class.Delivery = &Delivery{StampedIntoEnvelope: true, SharedInvariant: true} + d.Class.Consumers = []Consumer{ConsumerWorkerChunkEmbed} + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "monolith") +} + +func TestValidateSettingDefs_ReferenceImpliesSecret(t *testing.T) { + d := validBootstrapDef() + d.Class.ValueKind = ValueKindReference + d.Class.Secret = false + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "reference") +} + +func TestValidateSettingDefs_SeededImpliesOperational(t *testing.T) { + d := validBootstrapDef() + d.Seeded = true + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Seeded") +} + +func TestValidateSettingDefs_SeededRequiresDefault(t *testing.T) { + // Type "string" is otherwise exempt from the general Default + // requirement, but a Seeded key must still have a Default: it is + // written into system_settings at database init regardless of type. + d := validOperationalDef() + d.Type = "string" + d.Default = "" + d.Seeded = true + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Seeded setting must declare a Default") +} + +func TestValidateSettingDefs_SeededMustNotBeSecret(t *testing.T) { + d := validOperationalDef() + d.Class.Visibility = VisibilityAdminOnly // a public setting must not be Secret + d.Class.Secret = true + d.Seeded = true + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Seeded setting must not be Secret") +} + +func TestValidateSettingDefs_SeededNonSecretWithDefaultIsValid(t *testing.T) { + d := validOperationalDef() + d.Seeded = true + assert.NoError(t, ValidateSettingDefs([]SettingDef{d})) +} + +func TestValidateSettingDefs_SeededMustNotBeInternal(t *testing.T) { + // A seeded row is written into system_settings and listed by + // GET /admin/settings with no visibility filter, while GET/DELETE + // /admin/settings/{key} both 404 on VisibilityInternal — VisibilityInternal + // on a Seeded def would recreate #809. validOperationalDef() already has a + // non-empty Default and Secret=false, so Visibility is the only rule this + // fixture violates. + d := validOperationalDef() + d.Class.Visibility = VisibilityInternal + d.Seeded = true + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Seeded") + assert.Contains(t, err.Error(), "Internal") +} + +func TestValidateSettingDefs_RegistryItselfIsValid(t *testing.T) { + assert.NoError(t, ValidateSettingDefs(AllSettingDefs())) +} + +func TestValidateSettingDefs_TransitionalOperationalYAMLPathOptional(t *testing.T) { + d := validOperationalDef() + d.Transitional = true + d.EnvVar = "TMI_JWT_EXPIRATION_SECONDS" + d.Get = func(c *Config) string { return "60" } + // YAMLPath deliberately left empty, as for the derived + // session.timeout_minutes key. + assert.NoError(t, ValidateSettingDefs([]SettingDef{d})) +} + +func TestValidateSettingDefs_TransitionalOperationalEnvVarOptional(t *testing.T) { + d := validOperationalDef() + d.Transitional = true + d.YAMLPath = "administrators" + d.Get = func(c *Config) string { return "[]" } + // EnvVar deliberately left empty, as for administrators (config-file only, + // no env: tag). + assert.NoError(t, ValidateSettingDefs([]SettingDef{d})) +} + +func TestValidateSettingDefs_TransitionalOperationalStillRequiresGet(t *testing.T) { + d := validOperationalDef() + d.Transitional = true + d.YAMLPath = "ui.default_theme" + d.EnvVar = "TMI_UI_DEFAULT_THEME" + d.Get = nil + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Get") +} + +func TestValidateSettingDefs_SecretOperationalDefaultOptional(t *testing.T) { + d := validOperationalDef() + d.Default = "" + // A public setting must not be Secret, so switch visibility before + // marking it secret. + d.Class.Visibility = VisibilityAdminOnly + d.Class.Secret = true + assert.NoError(t, ValidateSettingDefs([]SettingDef{d})) +} + +func TestValidateSettingDefs_NonSecretOperationalStillRequiresDefault(t *testing.T) { + d := validOperationalDef() + d.Type = "bool" // "string" is separately exempt; see the string-typed test above + d.Default = "" + d.Class.Secret = false + err := ValidateSettingDefs([]SettingDef{d}) + require.Error(t, err) + assert.Contains(t, err.Error(), "Default") +} diff --git a/internal/config/setting_defs_auth.go b/internal/config/setting_defs_auth.go new file mode 100644 index 00000000..da49b9ab --- /dev/null +++ b/internal/config/setting_defs_auth.go @@ -0,0 +1,238 @@ +package config + +import ( + "encoding/json" + "strconv" +) + +// authSettingDefs declares the authentication and authorization settings. +var authSettingDefs = []SettingDef{ + { + Key: "auth.build_mode", + Class: bootstrapClass(true, VisibilityInternal, false), + Type: "string", + Description: "Build mode (dev, test, production)", + YAMLPath: "auth.build_mode", + EnvVar: "TMI_BUILD_MODE", + Get: func(c *Config) string { return c.Auth.BuildMode }, + }, + { + Key: "auth.jwt.secret", + Class: bootstrapClass(true, VisibilityInternal, true), + Type: "string", + Description: "JWT signing secret", + YAMLPath: "auth.jwt.secret", + EnvVar: "TMI_JWT_SECRET", + Get: func(c *Config) string { return c.Auth.JWT.Secret }, + }, + { + Key: "auth.jwt.signing_method", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "string", + Description: "JWT signing method", + YAMLPath: "auth.jwt.signing_method", + EnvVar: "TMI_JWT_SIGNING_METHOD", + Get: func(c *Config) string { return c.Auth.JWT.SigningMethod }, + }, + // The eight settings below (auto_promote_first_user through + // cookie.secure) are Static: each is read directly off the boot-time + // *Config struct by the code that consumes it — cmd/server/jwt_auth.go's + // AutoPromoteFirstUser check, auth/service.go's JWT issuance + // (ExpirationSeconds, RefreshTokenDays, SessionLifetimeDays), + // cmd/server/main.go's step-up-window and cookie-manager construction + // (StepUpWindowSeconds, Cookie.Enabled/Domain/Secure) — with no + // RuntimeConfigReader (or equivalent settings-service lookup) wired for + // any of them. Contrast with auth.everyone_is_a_reviewer directly below, + // which stays Hot: it has a DB-backed reader + // (JWTAuthenticator.everyoneIsAReviewer, memoized 60s). + { + Key: "auth.auto_promote_first_user", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "bool", + Description: "Auto-promote first user to admin", + Default: "false", + YAMLPath: "auth.auto_promote_first_user", + EnvVar: "TMI_AUTH_AUTO_PROMOTE_FIRST_USER", + Get: func(c *Config) string { return strconv.FormatBool(c.Auth.AutoPromoteFirstUser) }, + Transitional: true, + }, + { + Key: "auth.everyone_is_a_reviewer", + Class: operationalClass(VisibilityAdminOnly, false), + Type: "bool", + Description: "Auto-add all users to Security Reviewers group", + Default: "false", + YAMLPath: "auth.everyone_is_a_reviewer", + EnvVar: "TMI_AUTH_EVERYONE_IS_A_REVIEWER", + Get: func(c *Config) string { return strconv.FormatBool(c.Auth.EveryoneIsAReviewer) }, + Transitional: true, + }, + { + Key: "auth.jwt.expiration_seconds", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "int", + Description: "JWT token expiration in seconds", + Default: "3600", + YAMLPath: "auth.jwt.expiration_seconds", + EnvVar: "TMI_JWT_EXPIRATION_SECONDS", + Get: func(c *Config) string { return strconv.Itoa(c.Auth.JWT.ExpirationSeconds) }, + Transitional: true, + }, + { + Key: "auth.jwt.refresh_token_days", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "int", + Description: "Refresh token TTL in days", + Default: "7", + YAMLPath: "auth.jwt.refresh_token_days", + EnvVar: "TMI_REFRESH_TOKEN_DAYS", + Get: func(c *Config) string { return strconv.Itoa(c.Auth.JWT.RefreshTokenDays) }, + Transitional: true, + }, + { + Key: "auth.jwt.session_lifetime_days", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "int", + Description: "Absolute session lifetime in days", + Default: "7", + YAMLPath: "auth.jwt.session_lifetime_days", + EnvVar: "TMI_SESSION_LIFETIME_DAYS", + Get: func(c *Config) string { return strconv.Itoa(c.Auth.JWT.SessionLifetimeDays) }, + Transitional: true, + }, + { + Key: "auth.step_up_window_seconds", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "int", + Description: "Step-up auth_time freshness window in seconds for /admin/* writes (#355); minimum 60", + Default: "300", + YAMLPath: "auth.step_up_window_seconds", + EnvVar: "TMI_AUTH_STEP_UP_WINDOW_SECONDS", + Get: func(c *Config) string { return strconv.Itoa(c.Auth.StepUpWindowSeconds) }, + Transitional: true, + }, + { + Key: "auth.cookie.enabled", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "bool", + Description: "HttpOnly cookie-based auth enabled", + Default: "true", + YAMLPath: "auth.cookie.enabled", + EnvVar: "TMI_COOKIE_ENABLED", + Get: func(c *Config) string { return strconv.FormatBool(c.Auth.Cookie.Enabled) }, + Transitional: true, + }, + { + Key: "auth.cookie.domain", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "string", + Description: "Cookie domain", + Default: "", + YAMLPath: "auth.cookie.domain", + EnvVar: "TMI_COOKIE_DOMAIN", + Get: func(c *Config) string { return c.Auth.Cookie.Domain }, + Transitional: true, + }, + { + Key: "auth.cookie.secure", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "bool", + Description: "Require HTTPS for cookies", + Default: "false", + YAMLPath: "auth.cookie.secure", + EnvVar: "TMI_COOKIE_SECURE", + Get: func(c *Config) string { return strconv.FormatBool(c.Auth.Cookie.Secure) }, + Transitional: true, + }, + { + // Key differs from YAMLPath: the setting key is flat + // ("auth.oauth_callback_url"), matching the pre-existing migratable + // setting key, while the struct path goes through the nested OAuth + // config ("auth.oauth.callback_url"). + Key: "auth.oauth_callback_url", + Class: operationalClass(VisibilityAdminOnly, false), + Type: "string", + Description: "OAuth callback URL", + Default: "http://localhost:8080/oauth2/callback", + YAMLPath: "auth.oauth.callback_url", + EnvVar: "TMI_OAUTH_CALLBACK_URL", + Get: func(c *Config) string { return c.Auth.OAuth.CallbackURL }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "auth.oauth.client_callback_allowlist", + Class: operationalClass(VisibilityAdminOnly, false), + Type: "json", + Description: "Allowlist of client_callback URLs for /oauth2/authorize and /oauth2/step_up (exact URL or wildcard pattern ending in '*')", + Default: "[]", + YAMLPath: "auth.oauth.client_callback_allowlist", + EnvVar: "TMI_OAUTH_CLIENT_CALLBACK_ALLOWLIST", + Get: func(c *Config) string { + if len(c.Auth.OAuth.ClientCallbackAllowList) == 0 { + return "[]" + } + b, err := json.Marshal(c.Auth.OAuth.ClientCallbackAllowList) + if err != nil { + return "[]" + } + return string(b) + }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + // Key differs from YAMLPath: the setting key is the legacy flat + // feature-flag name ("features.saml_enabled"), while the struct path + // is the nested SAML config's enabled flag ("auth.saml.enabled"). + // + // Static: auth/service.go's NewService only builds service.samlManager + // when config.SAML.Enabled is true at construction time (auth/service.go + // ~line 148, "if config.SAML.Enabled { samlManager := NewSAMLManager(...) }"). + // A separate DB-backed reader (RuntimeConfigReader.IsSAMLEnabled) does + // exist and gates the SAML HTTP handlers per request, but if the manager + // was never constructed at boot, flipping the DB row does not make SAML + // login work — samlManager stays nil until restart. The setting that + // actually controls whether SAML functions is the boot-time one. + Key: "features.saml_enabled", + Class: withMutability(operationalClass(VisibilityPublic, false, ConsumerMonolith, ConsumerTMIUX), MutabilityStatic), + Type: "bool", + Description: "Enable SAML authentication", + Default: "false", + YAMLPath: "auth.saml.enabled", + EnvVar: "TMI_SAML_ENABLED", + Get: func(c *Config) string { return strconv.FormatBool(c.Auth.SAML.Enabled) }, + Transitional: true, + Seeded: true, + }, + { + // Top-level Config field, not under Auth. No env tag exists on + // Administrators — it is config-file only (config.go:49, + // `yaml:"administrators"` with no `env:` tag), so EnvVar is + // genuinely empty; YAMLPath is real and stays "administrators". + // + // Static: cmd/server/main.go's admin-init loop reads + // cfg.Administrators exactly once at startup to seed/promote the + // configured admins. There is no runtime re-read — a database edit + // to this key does not add or remove an admin without a restart. + Key: "administrators", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "json", + Description: "Configured administrators", + Default: "[]", + YAMLPath: "administrators", + EnvVar: "", + Get: func(c *Config) string { + if len(c.Administrators) == 0 { + return "[]" + } + b, err := json.Marshal(c.Administrators) + if err != nil { + return "[]" + } + return string(b) + }, + Transitional: true, + OmitWhenEmpty: true, + }, +} diff --git a/internal/config/setting_defs_bijection_test.go b/internal/config/setting_defs_bijection_test.go new file mode 100644 index 00000000..4ea18fbb --- /dev/null +++ b/internal/config/setting_defs_bijection_test.go @@ -0,0 +1,200 @@ +package config + +import ( + "reflect" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +// envTaggedKeys walks the Config struct and returns every yaml path that +// carries an env tag, as "a.b.c" joined from the yaml tags along the path, +// mapped to that field's env tag name. Reflection is used here deliberately +// and only in tests. +func envTaggedKeys(t *testing.T) map[string]string { + t.Helper() + out := map[string]string{} + + var walk func(rt reflect.Type, prefix []string) + walk = func(rt reflect.Type, prefix []string) { + if rt.Kind() == reflect.Pointer { + rt = rt.Elem() + } + if rt.Kind() != reflect.Struct { + return + } + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + yamlTag := strings.Split(f.Tag.Get("yaml"), ",")[0] + if yamlTag == "-" { + continue + } + path := prefix + if yamlTag != "" { + path = append(append([]string{}, prefix...), yamlTag) + } + if env := f.Tag.Get("env"); env != "" { + out[strings.Join(path, ".")] = env + } + ft := f.Type + if ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct { + walk(ft, path) + } + } + } + walk(reflect.TypeOf(Config{}), nil) + return out +} + +// allYAMLPaths walks the Config struct and returns every yaml path that +// exists, as a set — regardless of whether that field also carries an env +// tag. This is the superset envTaggedKeys draws from: it is what lets a +// SettingDef's YAMLPath be checked for truthfulness even on a +// config-file-only field with no env binding at all (administrators, +// every ssrf.*.allowlist/schemes key). +func allYAMLPaths(t *testing.T) map[string]bool { + t.Helper() + out := map[string]bool{} + + var walk func(rt reflect.Type, prefix []string) + walk = func(rt reflect.Type, prefix []string) { + if rt.Kind() == reflect.Pointer { + rt = rt.Elem() + } + if rt.Kind() != reflect.Struct { + return + } + for i := 0; i < rt.NumField(); i++ { + f := rt.Field(i) + yamlTag := strings.Split(f.Tag.Get("yaml"), ",")[0] + if yamlTag == "-" { + continue + } + path := prefix + if yamlTag != "" { + path = append(append([]string{}, prefix...), yamlTag) + out[strings.Join(path, ".")] = true + } + ft := f.Type + if ft.Kind() == reflect.Pointer { + ft = ft.Elem() + } + if ft.Kind() == reflect.Struct { + walk(ft, path) + } + } + } + walk(reflect.TypeOf(Config{}), nil) + return out +} + +// envVarSet collapses envTaggedKeys down to the set of distinct env: tag +// values bound somewhere on Config, discarding which yaml path(s) they +// belong to. +func envVarSet(t *testing.T) map[string]bool { + structKeys := envTaggedKeys(t) + out := make(map[string]bool, len(structKeys)) + for _, env := range structKeys { + out[env] = true + } + return out +} + +// TestSettingDefs_BijectWithConfigStructEnvVars is the bijection test for +// the registry's EnvVar declarations. It is keyed on EnvVar rather than +// YAMLPath (a prior version of this test compared YAMLPath -> EnvVar maps, +// which had two problems: a def with a real YAMLPath but no env tag — e.g. +// administrators, ssrf.* — always registered as "extra" even though the +// yaml key genuinely exists, and TMI_JWT_EXPIRATION_SECONDS is legitimately +// claimed by two defs, auth.jwt.expiration_seconds and the derived +// session.timeout_minutes, which a map keyed by env var cannot represent +// without one silently overwriting the other). Comparing as sets tolerates +// both: YAMLPath truthfulness is checked separately by +// TestSettingDefs_YAMLPathsAreReal below, and a set has no trouble with two +// defs sharing one EnvVar. +func TestSettingDefs_BijectWithConfigStructEnvVars(t *testing.T) { + structEnvVars := envVarSet(t) + + declared := map[string]bool{} + for _, d := range AllSettingDefs() { + if d.EnvVar == "" { + continue + } + declared[d.EnvVar] = true + } + + var missing, extra []string + for k := range structEnvVars { + if !declared[k] { + missing = append(missing, k) + } + } + for k := range declared { + if !structEnvVars[k] { + extra = append(extra, k) + } + } + sort.Strings(missing) + sort.Strings(extra) + + assert.Empty(t, missing, "Config struct env: tags with no SettingDef claiming them") + assert.Empty(t, extra, "SettingDefs claiming an EnvVar the Config struct does not bind") +} + +// TestBootstrapDefs_EnvVarNamesMatchStructTags checks, for every def that +// declares a YAMLPath the struct walk also recognizes as env-tagged, that +// the def's EnvVar agrees with the struct tag. A def whose YAMLPath has no +// env tag (administrators, ssrf.*) is skipped here — reported instead by +// TestSettingDefs_YAMLPathsAreReal — and a def with no YAMLPath at all +// (the derived session.timeout_minutes) is skipped unconditionally. +func TestBootstrapDefs_EnvVarNamesMatchStructTags(t *testing.T) { + structKeys := envTaggedKeys(t) + for _, d := range AllSettingDefs() { + if d.YAMLPath == "" { + continue + } + want, ok := structKeys[d.YAMLPath] + if !ok { + continue // reported by the bijection test + } + assert.Equal(t, want, d.EnvVar, + "SettingDef %q declares EnvVar %q but the Config struct binds %q", + d.Key, d.EnvVar, want) + } +} + +// TestSettingDefs_YAMLPathsAreReal asserts every non-empty YAMLPath declared +// in the registry names an actual yaml-tagged Config struct field — +// including a field with no env: tag, like administrators and every +// ssrf.*.allowlist/schemes key. This closes the blind spot the EnvVar-keyed +// bijection test above cannot see: without this test, a def could declare +// any string (or an empty one) as its YAMLPath and nothing would catch the +// difference between "the path is real" and "the author wrote it once and +// moved on" — which is exactly what let administrators and the ssrf.* keys +// carry a false empty YAMLPath in an earlier version of this registry. +func TestSettingDefs_YAMLPathsAreReal(t *testing.T) { + realPaths := allYAMLPaths(t) + for _, d := range AllSettingDefs() { + if d.YAMLPath == "" { + continue + } + assert.True(t, realPaths[d.YAMLPath], + "SettingDef %q declares YAMLPath %q, which is not a yaml-tagged field on Config", d.Key, d.YAMLPath) + } +} + +func TestBootstrapDefs_GetReturnsConfiguredValue(t *testing.T) { + c := &Config{} + c.Server.Port = "9999" + + d, ok := DefFor("server.port") + assert.True(t, ok, "server.port must be declared") + if ok { + assert.Equal(t, "9999", d.Get(c)) + } +} diff --git a/internal/config/setting_defs_content.go b/internal/config/setting_defs_content.go new file mode 100644 index 00000000..e6c43d43 --- /dev/null +++ b/internal/config/setting_defs_content.go @@ -0,0 +1,765 @@ +package config + +import "strconv" + +// contentSettingDefs declares the Timmy and content-pipeline settings: +// timmy.*, content_extractors.* (including its extraction.async_enabled +// rename), content_oauth.callback_url, content_sources.*, and the top-level +// content_token_encryption_key. Per-provider content_oauth.providers..* +// keys are generated at runtime per configured provider and have no static +// SettingDef — see prefixClassifications in classification_registry.go. +var contentSettingDefs = []SettingDef{ + // --- Timmy: LLM chat --- + // timmy.enabled is the one Timmy key that stays Hot: cmd/server/main.go's + // cfgReader closure (feeding the runtime-swappable ContentSourceHolder) + // explicitly overlays it from settingsService.GetBool(ctx, "timmy.enabled") + // on every rebuild, on top of an otherwise-static *cfg snapshot — see that + // closure's own comment, "the live config reader snapshots *cfg ... then + // overlays timmy.enabled from the settings service". Every other Timmy + // key below (and every content_extractors.*/content_sources.*/ + // content_oauth.callback_url key later in this file) is read only from + // that static snapshot, which the same comment calls out directly: + // "most content-source fields live in the static config" — so they are + // Static, and each such def is wrapped in withMutability(..., MutabilityStatic) + // rather than repeating this comment at every entry. + { + Key: "timmy.enabled", + Class: classificationFor("timmy.enabled"), + Type: "bool", + Description: "Timmy AI assistant enabled", + Default: "false", + YAMLPath: "timmy.enabled", + EnvVar: "TMI_TIMMY_ENABLED", + Get: func(c *Config) string { return strconv.FormatBool(c.Timmy.Enabled) }, + Transitional: true, + }, + { + Key: "timmy.llm_provider", + Class: withMutability(classificationFor("timmy.llm_provider"), MutabilityStatic), + Type: "string", + Description: "LLM provider", + Default: "", + YAMLPath: "timmy.llm_provider", + EnvVar: "TMI_TIMMY_LLM_PROVIDER", + Get: func(c *Config) string { return c.Timmy.LLMProvider }, + Transitional: true, + }, + { + Key: "timmy.llm_model", + Class: withMutability(classificationFor("timmy.llm_model"), MutabilityStatic), + Type: "string", + Description: "LLM model", + Default: "", + YAMLPath: "timmy.llm_model", + EnvVar: "TMI_TIMMY_LLM_MODEL", + Get: func(c *Config) string { return c.Timmy.LLMModel }, + Transitional: true, + }, + { + Key: "timmy.llm_api_key", + Class: withMutability(classificationFor("timmy.llm_api_key"), MutabilityStatic), + Type: "string", + Description: "LLM API key", + Default: "", + YAMLPath: "timmy.llm_api_key", + EnvVar: "TMI_TIMMY_LLM_API_KEY", + Get: func(c *Config) string { return c.Timmy.LLMAPIKey }, + Transitional: true, + }, + { + Key: "timmy.llm_base_url", + Class: withMutability(classificationFor("timmy.llm_base_url"), MutabilityStatic), + Type: "string", + Description: "LLM API base URL", + Default: "", + YAMLPath: "timmy.llm_base_url", + EnvVar: "TMI_TIMMY_LLM_BASE_URL", + Get: func(c *Config) string { return c.Timmy.LLMBaseURL }, + Transitional: true, + }, + { + Key: "timmy.llm_max_tokens", + Class: withMutability(classificationFor("timmy.llm_max_tokens"), MutabilityStatic), + Type: "int", + Description: "Max tokens per chat completion (required by Anthropic; optional for OpenAI)", + Default: "4096", + YAMLPath: "timmy.llm_max_tokens", + EnvVar: "TMI_TIMMY_LLM_MAX_TOKENS", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.LLMMaxTokens) }, + Transitional: true, + }, + + // --- Timmy: text embedding profile (shared invariant between ingest and query) --- + { + Key: "timmy.text_embedding_provider", + Class: withMutability(classificationFor("timmy.text_embedding_provider"), MutabilityStatic), + Type: "string", + Description: "Text embedding provider", + Default: "", + YAMLPath: "timmy.text_embedding_provider", + EnvVar: "TMI_TIMMY_TEXT_EMBEDDING_PROVIDER", + Get: func(c *Config) string { return c.Timmy.TextEmbeddingProvider }, + Transitional: true, + }, + { + Key: "timmy.text_embedding_model", + Class: withMutability(classificationFor("timmy.text_embedding_model"), MutabilityStatic), + Type: "string", + Description: "Text embedding model — shared invariant between ingest and query", + Default: "", + YAMLPath: "timmy.text_embedding_model", + EnvVar: "TMI_TIMMY_TEXT_EMBEDDING_MODEL", + Get: func(c *Config) string { return c.Timmy.TextEmbeddingModel }, + Transitional: true, + }, + { + Key: "timmy.text_embedding_api_key", + Class: withMutability(classificationFor("timmy.text_embedding_api_key"), MutabilityStatic), + Type: "string", + Description: "Text embedding API key", + Default: "", + YAMLPath: "timmy.text_embedding_api_key", + EnvVar: "TMI_TIMMY_TEXT_EMBEDDING_API_KEY", + Get: func(c *Config) string { return c.Timmy.TextEmbeddingAPIKey }, + Transitional: true, + }, + { + Key: "timmy.text_embedding_base_url", + Class: withMutability(classificationFor("timmy.text_embedding_base_url"), MutabilityStatic), + Type: "string", + Description: "Text embedding API base URL — shared invariant", + Default: "", + YAMLPath: "timmy.text_embedding_base_url", + EnvVar: "TMI_TIMMY_TEXT_EMBEDDING_BASE_URL", + Get: func(c *Config) string { return c.Timmy.TextEmbeddingBaseURL }, + Transitional: true, + }, + { + Key: "timmy.embedding_dimension", + Class: withMutability(classificationFor("timmy.embedding_dimension"), MutabilityStatic), + Type: "int", + Description: "Text embedding vector dimension — shared invariant", + Default: "0", + YAMLPath: "timmy.embedding_dimension", + EnvVar: "TMI_TIMMY_EMBEDDING_DIMENSION", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.EmbeddingDimension) }, + Transitional: true, + }, + { + Key: "timmy.text_retrieval_top_k", + Class: withMutability(classificationFor("timmy.text_retrieval_top_k"), MutabilityStatic), + Type: "int", + Description: "Text retrieval top-k results", + Default: "10", + YAMLPath: "timmy.text_retrieval_top_k", + EnvVar: "TMI_TIMMY_TEXT_RETRIEVAL_TOP_K", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.TextRetrievalTopK) }, + Transitional: true, + }, + + // --- Timmy: code embedding profile --- + { + Key: "timmy.code_embedding_provider", + Class: withMutability(classificationFor("timmy.code_embedding_provider"), MutabilityStatic), + Type: "string", + Description: "Code embedding provider", + Default: "", + YAMLPath: "timmy.code_embedding_provider", + EnvVar: "TMI_TIMMY_CODE_EMBEDDING_PROVIDER", + Get: func(c *Config) string { return c.Timmy.CodeEmbeddingProvider }, + Transitional: true, + }, + { + Key: "timmy.code_embedding_model", + Class: withMutability(classificationFor("timmy.code_embedding_model"), MutabilityStatic), + Type: "string", + Description: "Code embedding model", + Default: "", + YAMLPath: "timmy.code_embedding_model", + EnvVar: "TMI_TIMMY_CODE_EMBEDDING_MODEL", + Get: func(c *Config) string { return c.Timmy.CodeEmbeddingModel }, + Transitional: true, + }, + { + Key: "timmy.code_embedding_api_key", + Class: withMutability(classificationFor("timmy.code_embedding_api_key"), MutabilityStatic), + Type: "string", + Description: "Code embedding API key", + Default: "", + YAMLPath: "timmy.code_embedding_api_key", + EnvVar: "TMI_TIMMY_CODE_EMBEDDING_API_KEY", + Get: func(c *Config) string { return c.Timmy.CodeEmbeddingAPIKey }, + Transitional: true, + }, + { + Key: "timmy.code_embedding_base_url", + Class: withMutability(classificationFor("timmy.code_embedding_base_url"), MutabilityStatic), + Type: "string", + Description: "Code embedding API base URL", + Default: "", + YAMLPath: "timmy.code_embedding_base_url", + EnvVar: "TMI_TIMMY_CODE_EMBEDDING_BASE_URL", + Get: func(c *Config) string { return c.Timmy.CodeEmbeddingBaseURL }, + Transitional: true, + }, + { + Key: "timmy.code_retrieval_top_k", + Class: withMutability(classificationFor("timmy.code_retrieval_top_k"), MutabilityStatic), + Type: "int", + Description: "Code retrieval top-k results", + Default: "10", + YAMLPath: "timmy.code_retrieval_top_k", + EnvVar: "TMI_TIMMY_CODE_RETRIEVAL_TOP_K", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.CodeRetrievalTopK) }, + Transitional: true, + }, + { + Key: "timmy.query_decomposition_enabled", + Class: withMutability(classificationFor("timmy.query_decomposition_enabled"), MutabilityStatic), + Type: "bool", + Description: "Query decomposition enabled", + Default: "false", + YAMLPath: "timmy.query_decomposition_enabled", + EnvVar: "TMI_TIMMY_QUERY_DECOMPOSITION_ENABLED", + Get: func(c *Config) string { return strconv.FormatBool(c.Timmy.QueryDecompositionEnabled) }, + Transitional: true, + }, + + // --- Timmy: reranker --- + { + Key: "timmy.rerank_provider", + Class: withMutability(classificationFor("timmy.rerank_provider"), MutabilityStatic), + Type: "string", + Description: "Reranker provider", + Default: "", + YAMLPath: "timmy.rerank_provider", + EnvVar: "TMI_TIMMY_RERANK_PROVIDER", + Get: func(c *Config) string { return c.Timmy.RerankProvider }, + Transitional: true, + }, + { + Key: "timmy.rerank_model", + Class: withMutability(classificationFor("timmy.rerank_model"), MutabilityStatic), + Type: "string", + Description: "Reranker model", + Default: "", + YAMLPath: "timmy.rerank_model", + EnvVar: "TMI_TIMMY_RERANK_MODEL", + Get: func(c *Config) string { return c.Timmy.RerankModel }, + Transitional: true, + }, + { + Key: "timmy.rerank_api_key", + Class: withMutability(classificationFor("timmy.rerank_api_key"), MutabilityStatic), + Type: "string", + Description: "Reranker API key", + Default: "", + YAMLPath: "timmy.rerank_api_key", + EnvVar: "TMI_TIMMY_RERANK_API_KEY", + Get: func(c *Config) string { return c.Timmy.RerankAPIKey }, + Transitional: true, + }, + { + Key: "timmy.rerank_base_url", + Class: withMutability(classificationFor("timmy.rerank_base_url"), MutabilityStatic), + Type: "string", + Description: "Reranker API base URL", + Default: "", + YAMLPath: "timmy.rerank_base_url", + EnvVar: "TMI_TIMMY_RERANK_BASE_URL", + Get: func(c *Config) string { return c.Timmy.RerankBaseURL }, + Transitional: true, + }, + { + Key: "timmy.rerank_top_k", + Class: withMutability(classificationFor("timmy.rerank_top_k"), MutabilityStatic), + Type: "int", + Description: "Reranker top-k results", + Default: "10", + YAMLPath: "timmy.rerank_top_k", + EnvVar: "TMI_TIMMY_RERANK_TOP_K", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.RerankTopK) }, + Transitional: true, + }, + + // --- Timmy: session / resource limits --- + { + Key: "timmy.max_conversation_history", + Class: withMutability(classificationFor("timmy.max_conversation_history"), MutabilityStatic), + Type: "int", + Description: "Max conversation history entries", + Default: "50", + YAMLPath: "timmy.max_conversation_history", + EnvVar: "TMI_TIMMY_MAX_CONVERSATION_HISTORY", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.MaxConversationHistory) }, + Transitional: true, + }, + { + Key: "timmy.operator_system_prompt", + Class: withMutability(classificationFor("timmy.operator_system_prompt"), MutabilityStatic), + Type: "string", + Description: "Operator system prompt override", + Default: "", + YAMLPath: "timmy.operator_system_prompt", + EnvVar: "TMI_TIMMY_OPERATOR_SYSTEM_PROMPT", + Get: func(c *Config) string { return c.Timmy.OperatorSystemPrompt }, + Transitional: true, + }, + { + Key: "timmy.max_memory_mb", + Class: withMutability(classificationFor("timmy.max_memory_mb"), MutabilityStatic), + Type: "int", + Description: "Max memory in MB", + Default: "256", + YAMLPath: "timmy.max_memory_mb", + EnvVar: "TMI_TIMMY_MAX_MEMORY_MB", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.MaxMemoryMB) }, + Transitional: true, + }, + { + Key: "timmy.inactivity_timeout_seconds", + Class: withMutability(classificationFor("timmy.inactivity_timeout_seconds"), MutabilityStatic), + Type: "int", + Description: "Session inactivity timeout in seconds", + Default: "3600", + YAMLPath: "timmy.inactivity_timeout_seconds", + EnvVar: "TMI_TIMMY_INACTIVITY_TIMEOUT_SECONDS", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.InactivityTimeoutSeconds) }, + Transitional: true, + }, + { + Key: "timmy.max_messages_per_user_per_hour", + Class: withMutability(classificationFor("timmy.max_messages_per_user_per_hour"), MutabilityStatic), + Type: "int", + Description: "Max messages per user per hour", + Default: "60", + YAMLPath: "timmy.max_messages_per_user_per_hour", + EnvVar: "TMI_TIMMY_MAX_MESSAGES_PER_USER_PER_HOUR", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.MaxMessagesPerUserPerHour) }, + Transitional: true, + }, + { + Key: "timmy.max_sessions_per_threat_model", + Class: withMutability(classificationFor("timmy.max_sessions_per_threat_model"), MutabilityStatic), + Type: "int", + Description: "Max Timmy sessions per threat model", + Default: "50", + YAMLPath: "timmy.max_sessions_per_threat_model", + EnvVar: "TMI_TIMMY_MAX_SESSIONS_PER_THREAT_MODEL", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.MaxSessionsPerThreatModel) }, + Transitional: true, + }, + { + Key: "timmy.max_concurrent_llm_requests", + Class: withMutability(classificationFor("timmy.max_concurrent_llm_requests"), MutabilityStatic), + Type: "int", + Description: "Max concurrent LLM requests", + Default: "10", + YAMLPath: "timmy.max_concurrent_llm_requests", + EnvVar: "TMI_TIMMY_MAX_CONCURRENT_LLM_REQUESTS", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.MaxConcurrentLLMRequests) }, + Transitional: true, + }, + + // --- Timmy: chunking / embedding lifecycle --- + { + Key: "timmy.chunk_size", + Class: withMutability(classificationFor("timmy.chunk_size"), MutabilityStatic), + Type: "int", + Description: "Embedding chunk size", + Default: "512", + YAMLPath: "timmy.chunk_size", + EnvVar: "TMI_TIMMY_CHUNK_SIZE", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.ChunkSize) }, + Transitional: true, + }, + { + Key: "timmy.chunk_overlap", + Class: withMutability(classificationFor("timmy.chunk_overlap"), MutabilityStatic), + Type: "int", + Description: "Embedding chunk overlap", + Default: "50", + YAMLPath: "timmy.chunk_overlap", + EnvVar: "TMI_TIMMY_CHUNK_OVERLAP", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.ChunkOverlap) }, + Transitional: true, + }, + { + Key: "timmy.llm_timeout_seconds", + Class: withMutability(classificationFor("timmy.llm_timeout_seconds"), MutabilityStatic), + Type: "int", + Description: "LLM request timeout in seconds", + Default: "120", + YAMLPath: "timmy.llm_timeout_seconds", + EnvVar: "TMI_TIMMY_LLM_TIMEOUT_SECONDS", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.LLMTimeoutSeconds) }, + Transitional: true, + }, + { + Key: "timmy.embedding_cleanup_interval_minutes", + Class: withMutability(classificationFor("timmy.embedding_cleanup_interval_minutes"), MutabilityStatic), + Type: "int", + Description: "Embedding cleanup interval in minutes", + Default: "60", + YAMLPath: "timmy.embedding_cleanup_interval_minutes", + EnvVar: "TMI_TIMMY_EMBEDDING_CLEANUP_INTERVAL_MINUTES", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.EmbeddingCleanupIntervalMinutes) }, + Transitional: true, + }, + { + Key: "timmy.embedding_idle_days_active", + Class: withMutability(classificationFor("timmy.embedding_idle_days_active"), MutabilityStatic), + Type: "int", + Description: "Days before idle active-TM embeddings are cleaned up", + Default: "30", + YAMLPath: "timmy.embedding_idle_days_active", + EnvVar: "TMI_TIMMY_EMBEDDING_IDLE_DAYS_ACTIVE", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.EmbeddingIdleDaysActive) }, + Transitional: true, + }, + { + Key: "timmy.embedding_idle_days_closed", + Class: withMutability(classificationFor("timmy.embedding_idle_days_closed"), MutabilityStatic), + Type: "int", + Description: "Days before idle closed-TM embeddings are cleaned up", + Default: "7", + YAMLPath: "timmy.embedding_idle_days_closed", + EnvVar: "TMI_TIMMY_EMBEDDING_IDLE_DAYS_CLOSED", + Get: func(c *Config) string { return strconv.Itoa(c.Timmy.EmbeddingIdleDaysClosed) }, + Transitional: true, + }, + { + Key: "timmy.dump_extracted_text_to_note", + Class: withMutability(classificationFor("timmy.dump_extracted_text_to_note"), MutabilityStatic), + Type: "bool", + Description: "Dump extracted text to note (dev/test only)", + Default: "false", + YAMLPath: "timmy.dump_extracted_text_to_note", + EnvVar: "TMI_TIMMY_DUMP_EXTRACTED_TEXT_TO_NOTE", + Get: func(c *Config) string { return strconv.FormatBool(c.Timmy.DumpExtractedTextToNote) }, + Transitional: true, + }, + + // --- Content extractors: OOXML pipeline limits --- + { + Key: "content_extractors.compressed_size_bytes", + Class: withMutability(classificationFor("content_extractors.compressed_size_bytes"), MutabilityStatic), + Type: "int", + Description: "Max compressed upload size in bytes", + Default: "20971520", + YAMLPath: "content_extractors.compressed_size_bytes", + EnvVar: "TMI_CONTENT_EXTRACTORS_COMPRESSED_SIZE_BYTES", + Get: func(c *Config) string { return strconv.FormatInt(c.ContentExtractors.CompressedSizeBytes, 10) }, + Transitional: true, + }, + { + Key: "content_extractors.decompressed_size_bytes", + Class: withMutability(classificationFor("content_extractors.decompressed_size_bytes"), MutabilityStatic), + Type: "int", + Description: "Max decompressed content size in bytes", + Default: "52428800", + YAMLPath: "content_extractors.decompressed_size_bytes", + EnvVar: "TMI_CONTENT_EXTRACTORS_DECOMPRESSED_SIZE_BYTES", + Get: func(c *Config) string { return strconv.FormatInt(c.ContentExtractors.DecompressedSizeBytes, 10) }, + Transitional: true, + }, + { + Key: "content_extractors.part_size_bytes", + Class: withMutability(classificationFor("content_extractors.part_size_bytes"), MutabilityStatic), + Type: "int", + Description: "Max size of a single archive part in bytes", + Default: "20971520", + YAMLPath: "content_extractors.part_size_bytes", + EnvVar: "TMI_CONTENT_EXTRACTORS_PART_SIZE_BYTES", + Get: func(c *Config) string { return strconv.FormatInt(c.ContentExtractors.PartSizeBytes, 10) }, + Transitional: true, + }, + { + Key: "content_extractors.pptx_slides", + Class: withMutability(classificationFor("content_extractors.pptx_slides"), MutabilityStatic), + Type: "int", + Description: "Max number of PowerPoint slides to extract", + Default: "100", + YAMLPath: "content_extractors.pptx_slides", + EnvVar: "TMI_CONTENT_EXTRACTORS_PPTX_SLIDES", + Get: func(c *Config) string { return strconv.Itoa(c.ContentExtractors.PPTXSlides) }, + Transitional: true, + }, + { + Key: "content_extractors.xlsx_cells", + Class: withMutability(classificationFor("content_extractors.xlsx_cells"), MutabilityStatic), + Type: "int", + Description: "Max number of Excel cells to extract", + Default: "1000", + YAMLPath: "content_extractors.xlsx_cells", + EnvVar: "TMI_CONTENT_EXTRACTORS_XLSX_CELLS", + Get: func(c *Config) string { return strconv.Itoa(c.ContentExtractors.XLSXCells) }, + Transitional: true, + }, + { + Key: "content_extractors.markdown_size_bytes", + Class: withMutability(classificationFor("content_extractors.markdown_size_bytes"), MutabilityStatic), + Type: "int", + Description: "Max markdown output size in bytes", + Default: "131072", + YAMLPath: "content_extractors.markdown_size_bytes", + EnvVar: "TMI_CONTENT_EXTRACTORS_MARKDOWN_SIZE_BYTES", + Get: func(c *Config) string { return strconv.FormatInt(c.ContentExtractors.MarkdownSizeBytes, 10) }, + Transitional: true, + }, + { + Key: "content_extractors.wall_clock_budget", + Class: withMutability(classificationFor("content_extractors.wall_clock_budget"), MutabilityStatic), + Type: "string", + Description: "Max wall-clock time for a single extraction", + Default: "30s", + YAMLPath: "content_extractors.wall_clock_budget", + EnvVar: "TMI_CONTENT_EXTRACTORS_WALL_CLOCK_BUDGET", + Get: func(c *Config) string { return c.ContentExtractors.WallClockBudget.String() }, + Transitional: true, + }, + { + Key: "content_extractors.per_user_concurrency_default", + Class: withMutability(classificationFor("content_extractors.per_user_concurrency_default"), MutabilityStatic), + Type: "int", + Description: "Default max concurrent extractions per user", + Default: "2", + YAMLPath: "content_extractors.per_user_concurrency_default", + EnvVar: "TMI_CONTENT_EXTRACTORS_PER_USER_CONCURRENCY_DEFAULT", + Get: func(c *Config) string { return strconv.Itoa(c.ContentExtractors.PerUserConcurrencyDefault) }, + Transitional: true, + }, + // content_extractors.async_enabled is emitted (and classified) under the + // renamed key extraction.async_enabled — see + // ExpectedMigratableKeysSkipped's "content_extractors.async_enabled" + // entry in migratable_discovery.go. YAMLPath still reflects the struct's + // actual location; Key matches the canonical settings/classification key. + { + Key: "extraction.async_enabled", + Class: classificationFor("extraction.async_enabled"), + Type: "bool", + Description: "Route document extraction through the async worker pipeline instead of inline (default false; requires NATS)", + Default: "false", + YAMLPath: "content_extractors.async_enabled", + EnvVar: "TMI_EXTRACTION_ASYNC_ENABLED", + Get: func(c *Config) string { return strconv.FormatBool(c.ContentExtractors.AsyncEnabled) }, + Transitional: true, + }, + + // --- Content OAuth: callback URL (per-provider keys excluded — dynamic cardinality) --- + { + Key: "content_oauth.callback_url", + Class: withMutability(classificationFor("content_oauth.callback_url"), MutabilityStatic), + Type: "string", + Description: "Content OAuth callback URL", + Default: "", + YAMLPath: "content_oauth.callback_url", + EnvVar: "TMI_CONTENT_OAUTH_CALLBACK_URL", + Get: func(c *Config) string { return c.ContentOAuth.CallbackURL }, + Transitional: true, + OmitWhenEmpty: true, + }, + + // --- Content sources: Google Drive --- + { + Key: "content_sources.google_drive.enabled", + Class: withMutability(classificationFor("content_sources.google_drive.enabled"), MutabilityStatic), + Type: "bool", + Description: "Google Drive content source enabled", + Default: "false", + YAMLPath: "content_sources.google_drive.enabled", + EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_DRIVE_ENABLED", + Get: func(c *Config) string { return strconv.FormatBool(c.ContentSources.GoogleDrive.Enabled) }, + Transitional: true, + }, + { + Key: "content_sources.google_drive.service_account_email", + Class: withMutability(classificationFor("content_sources.google_drive.service_account_email"), MutabilityStatic), + Type: "string", + Description: "Google Drive service account email", + Default: "", + YAMLPath: "content_sources.google_drive.service_account_email", + EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL", + Get: func(c *Config) string { return c.ContentSources.GoogleDrive.ServiceAccountEmail }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "content_sources.google_drive.credentials_file", + Class: withMutability(classificationFor("content_sources.google_drive.credentials_file"), MutabilityStatic), + Type: "string", + Description: "Google Drive service account credentials file path", + Default: "", + YAMLPath: "content_sources.google_drive.credentials_file", + EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_DRIVE_CREDENTIALS_FILE", + Get: func(c *Config) string { return c.ContentSources.GoogleDrive.CredentialsFile }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "content_sources.google_drive.browser_oauth_client_id", + Class: withMutability(classificationFor("content_sources.google_drive.browser_oauth_client_id"), MutabilityStatic), + Type: "string", + Description: "Google Drive browser OAuth client ID (public)", + Default: "", + YAMLPath: "content_sources.google_drive.browser_oauth_client_id", + EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_DRIVE_BROWSER_OAUTH_CLIENT_ID", + Get: func(c *Config) string { return c.ContentSources.GoogleDrive.BrowserOAuthClientID }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "content_sources.google_drive.picker_developer_key", + Class: withMutability(classificationFor("content_sources.google_drive.picker_developer_key"), MutabilityStatic), + Type: "string", + Description: "Google Drive Picker developer key (public)", + Default: "", + YAMLPath: "content_sources.google_drive.picker_developer_key", + EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_DRIVE_PICKER_DEVELOPER_KEY", + Get: func(c *Config) string { return c.ContentSources.GoogleDrive.PickerDeveloperKey }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "content_sources.google_drive.picker_app_id", + Class: withMutability(classificationFor("content_sources.google_drive.picker_app_id"), MutabilityStatic), + Type: "string", + Description: "Google Drive Picker app ID (public)", + Default: "", + YAMLPath: "content_sources.google_drive.picker_app_id", + EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_DRIVE_PICKER_APP_ID", + Get: func(c *Config) string { return c.ContentSources.GoogleDrive.PickerAppID }, + Transitional: true, + OmitWhenEmpty: true, + }, + + // --- Content sources: Google Workspace --- + { + Key: "content_sources.google_workspace.enabled", + Class: withMutability(classificationFor("content_sources.google_workspace.enabled"), MutabilityStatic), + Type: "bool", + Description: "Google Workspace content source enabled", + Default: "false", + YAMLPath: "content_sources.google_workspace.enabled", + EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_WORKSPACE_ENABLED", + Get: func(c *Config) string { return strconv.FormatBool(c.ContentSources.GoogleWorkspace.Enabled) }, + Transitional: true, + }, + { + Key: "content_sources.google_workspace.picker_developer_key", + Class: withMutability(classificationFor("content_sources.google_workspace.picker_developer_key"), MutabilityStatic), + Type: "string", + Description: "Google Workspace Picker developer key (public)", + Default: "", + YAMLPath: "content_sources.google_workspace.picker_developer_key", + EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_WORKSPACE_PICKER_DEVELOPER_KEY", + Get: func(c *Config) string { return c.ContentSources.GoogleWorkspace.PickerDeveloperKey }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "content_sources.google_workspace.picker_app_id", + Class: withMutability(classificationFor("content_sources.google_workspace.picker_app_id"), MutabilityStatic), + Type: "string", + Description: "Google Workspace Picker app ID (public)", + Default: "", + YAMLPath: "content_sources.google_workspace.picker_app_id", + EnvVar: "TMI_CONTENT_SOURCE_GOOGLE_WORKSPACE_PICKER_APP_ID", + Get: func(c *Config) string { return c.ContentSources.GoogleWorkspace.PickerAppID }, + Transitional: true, + OmitWhenEmpty: true, + }, + + // --- Content sources: Confluence --- + { + Key: "content_sources.confluence.enabled", + Class: withMutability(classificationFor("content_sources.confluence.enabled"), MutabilityStatic), + Type: "bool", + Description: "Confluence content source enabled", + Default: "false", + YAMLPath: "content_sources.confluence.enabled", + EnvVar: "TMI_CONTENT_SOURCE_CONFLUENCE_ENABLED", + Get: func(c *Config) string { return strconv.FormatBool(c.ContentSources.Confluence.Enabled) }, + Transitional: true, + }, + + // --- Content sources: Microsoft --- + { + Key: "content_sources.microsoft.enabled", + Class: withMutability(classificationFor("content_sources.microsoft.enabled"), MutabilityStatic), + Type: "bool", + Description: "Microsoft content source enabled", + Default: "false", + YAMLPath: "content_sources.microsoft.enabled", + EnvVar: "TMI_CONTENT_SOURCE_MICROSOFT_ENABLED", + Get: func(c *Config) string { return strconv.FormatBool(c.ContentSources.Microsoft.Enabled) }, + Transitional: true, + }, + { + Key: "content_sources.microsoft.tenant_id", + Class: withMutability(classificationFor("content_sources.microsoft.tenant_id"), MutabilityStatic), + Type: "string", + Description: "Microsoft Entra tenant ID", + Default: "", + YAMLPath: "content_sources.microsoft.tenant_id", + EnvVar: "TMI_CONTENT_SOURCE_MICROSOFT_TENANT_ID", + Get: func(c *Config) string { return c.ContentSources.Microsoft.TenantID }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "content_sources.microsoft.client_id", + Class: withMutability(classificationFor("content_sources.microsoft.client_id"), MutabilityStatic), + Type: "string", + Description: "Microsoft Entra app client ID (public)", + Default: "", + YAMLPath: "content_sources.microsoft.client_id", + EnvVar: "TMI_CONTENT_SOURCE_MICROSOFT_CLIENT_ID", + Get: func(c *Config) string { return c.ContentSources.Microsoft.ClientID }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "content_sources.microsoft.application_object_id", + Class: withMutability(classificationFor("content_sources.microsoft.application_object_id"), MutabilityStatic), + Type: "string", + Description: "Microsoft Entra application object ID", + Default: "", + YAMLPath: "content_sources.microsoft.application_object_id", + EnvVar: "TMI_CONTENT_SOURCE_MICROSOFT_APPLICATION_OBJECT_ID", + Get: func(c *Config) string { return c.ContentSources.Microsoft.ApplicationObjectID }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "content_sources.microsoft.picker_origin", + Class: withMutability(classificationFor("content_sources.microsoft.picker_origin"), MutabilityStatic), + Type: "string", + Description: "Microsoft Picker allowed origin URL", + Default: "", + YAMLPath: "content_sources.microsoft.picker_origin", + EnvVar: "TMI_CONTENT_SOURCE_MICROSOFT_PICKER_ORIGIN", + Get: func(c *Config) string { return c.ContentSources.Microsoft.PickerOrigin }, + Transitional: true, + OmitWhenEmpty: true, + }, + + // --- Top-level: content token encryption key --- + // content_token_encryption_key has NO entry in exactClassifications or + // prefixClassifications (classification_registry.go) — classificationFor + // would return the zero ConfigClass (CategoryUnclassified), which fails + // validation. Declared here with the bootstrapClass fallback shape + // instead, with Secret:true: it is an encryption key, and the + // alternatives (a non-secret classification, or a hardcoded default) are + // both unsafe. + { + Key: "content_token_encryption_key", + Class: bootstrapClass(false, VisibilityInternal, true), + Type: "string", + Description: "Encryption key used to encrypt content provider OAuth tokens at rest", + Default: "", + YAMLPath: "content_token_encryption_key", + EnvVar: "TMI_CONTENT_TOKEN_ENCRYPTION_KEY", + Get: func(c *Config) string { return c.ContentTokenEncryptionKey }, + }, +} diff --git a/internal/config/setting_defs_misc.go b/internal/config/setting_defs_misc.go new file mode 100644 index 00000000..5270b20b --- /dev/null +++ b/internal/config/setting_defs_misc.go @@ -0,0 +1,474 @@ +package config + +import "strconv" + +// miscSettingDefs declares the sections not covered by the other +// setting_defs_*.go files: websocket.*, webhooks.*, operator.*, secrets.* +// (the secrets-provider bootstrap block), alerting.* (the audit alert sink), +// ssrf.* (per-URI-class allowlists), the DB-only client-config keys +// (features.webhooks_enabled, features.websocket_enabled, +// websocket.max_participants, upload.max_file_size_mb, ui.default_theme), +// the two dead rate_limit.* keys (rate_limit.requests_per_minute, +// rate_limit.requests_per_hour — see the comment above their declaration), +// and the one derived key with no Config struct field of its own, +// session.timeout_minutes. See setting_def.go for the SettingDef contract +// and classification_registry.go for the source of truth on each key's +// ConfigClass. +var miscSettingDefs = []SettingDef{ + // --- websocket.* --- + // Static: cmd/server/main.go passes config.GetWebSocketInactivityTimeout() + // once into api.NewServer(...), which stores it on the WebSocketHub as + // InactivityTimeout. There is no runtime re-read of this setting. + // Contrast with operator.*/upload.max_file_size_mb/websocket.max_participants/ + // ui.default_theme below, which api/config_handlers.go re-reads via + // settingsService.GetString/GetInt on every /config request — those stay Hot. + { + Key: "websocket.inactivity_timeout_seconds", + Class: withMutability(classificationFor("websocket.inactivity_timeout_seconds"), MutabilityStatic), + Type: "int", + Description: "WebSocket inactivity timeout in seconds", + Default: "300", + YAMLPath: "websocket.inactivity_timeout_seconds", + EnvVar: "TMI_WEBSOCKET_INACTIVITY_TIMEOUT_SECONDS", + Get: func(c *Config) string { return strconv.Itoa(c.WebSocket.InactivityTimeoutSeconds) }, + Transitional: true, + OmitWhenEmpty: true, + }, + + // --- webhooks.* --- + // Static: cmd/server/main.go reads config.Webhooks.AllowHTTPTargets once + // at startup — apiServer.SetAllowHTTPWebhooks(...) and the webhook SSRF + // validator's scheme setup both run only during initialization. + { + Key: "webhooks.allow_http_targets", + Class: withMutability(classificationFor("webhooks.allow_http_targets"), MutabilityStatic), + Type: "bool", + Description: "Allow non-HTTPS webhook target URLs (intra-cluster use only)", + Default: "false", + YAMLPath: "webhooks.allow_http_targets", + EnvVar: "TMI_WEBHOOK_ALLOW_HTTP_TARGETS", + Get: func(c *Config) string { return strconv.FormatBool(c.Webhooks.AllowHTTPTargets) }, + Transitional: true, + }, + + // --- operator.* --- + { + Key: "operator.name", + Class: classificationFor("operator.name"), + Type: "string", + Description: "Operator/maintainer name", + Default: "", + YAMLPath: "operator.name", + EnvVar: "TMI_OPERATOR_NAME", + Get: func(c *Config) string { return c.Operator.Name }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "operator.contact", + Class: classificationFor("operator.contact"), + Type: "string", + Description: "Operator contact information", + Default: "", + YAMLPath: "operator.contact", + EnvVar: "TMI_OPERATOR_CONTACT", + Get: func(c *Config) string { return c.Operator.Contact }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "operator.jurisdiction", + Class: classificationFor("operator.jurisdiction"), + Type: "string", + Description: "Legal jurisdiction under which the service operates", + Default: "", + YAMLPath: "operator.jurisdiction", + EnvVar: "TMI_OPERATOR_JURISDICTION", + Get: func(c *Config) string { return c.Operator.Jurisdiction }, + Transitional: true, + OmitWhenEmpty: true, + }, + + // --- secrets.* (bootstrap: the secrets-provider backend coordinates) --- + { + Key: "secrets.provider", + Class: classificationFor("secrets.provider"), + Type: "string", + Description: "Secret provider type", + YAMLPath: "secrets.provider", + EnvVar: "TMI_SECRETS_PROVIDER", + Get: func(c *Config) string { return c.Secrets.Provider }, + }, + { + Key: "secrets.vault_address", + Class: classificationFor("secrets.vault_address"), + Type: "string", + Description: "HashiCorp Vault address", + YAMLPath: "secrets.vault_address", + EnvVar: "TMI_VAULT_ADDRESS", + Get: func(c *Config) string { return c.Secrets.VaultAddress }, + OmitWhenEmpty: true, + }, + { + Key: "secrets.vault_path", + Class: classificationFor("secrets.vault_path"), + Type: "string", + Description: "HashiCorp Vault path", + YAMLPath: "secrets.vault_path", + EnvVar: "TMI_VAULT_PATH", + Get: func(c *Config) string { return c.Secrets.VaultPath }, + OmitWhenEmpty: true, + }, + { + Key: "secrets.vault_token", + Class: classificationFor("secrets.vault_token"), + Type: "string", + Description: "HashiCorp Vault token", + YAMLPath: "secrets.vault_token", + EnvVar: "TMI_VAULT_TOKEN", + Get: func(c *Config) string { return c.Secrets.VaultToken }, + }, + { + Key: "secrets.aws_region", + Class: classificationFor("secrets.aws_region"), + Type: "string", + Description: "AWS region", + YAMLPath: "secrets.aws_region", + EnvVar: "TMI_AWS_REGION", + Get: func(c *Config) string { return c.Secrets.AWSRegion }, + OmitWhenEmpty: true, + }, + { + Key: "secrets.aws_secret_name", + Class: classificationFor("secrets.aws_secret_name"), + Type: "string", + Description: "AWS secret name", + YAMLPath: "secrets.aws_secret_name", + EnvVar: "TMI_AWS_SECRET_NAME", + Get: func(c *Config) string { return c.Secrets.AWSSecretName }, + OmitWhenEmpty: true, + }, + { + Key: "secrets.azure_vault_url", + Class: classificationFor("secrets.azure_vault_url"), + Type: "string", + Description: "Azure Key Vault URL", + YAMLPath: "secrets.azure_vault_url", + EnvVar: "TMI_AZURE_VAULT_URL", + Get: func(c *Config) string { return c.Secrets.AzureVaultURL }, + OmitWhenEmpty: true, + }, + { + Key: "secrets.gcp_project_id", + Class: classificationFor("secrets.gcp_project_id"), + Type: "string", + Description: "GCP project ID", + YAMLPath: "secrets.gcp_project_id", + EnvVar: "TMI_GCP_PROJECT_ID", + Get: func(c *Config) string { return c.Secrets.GCPProjectID }, + OmitWhenEmpty: true, + }, + { + Key: "secrets.gcp_secret_name", + Class: classificationFor("secrets.gcp_secret_name"), + Type: "string", + Description: "GCP secret name", + YAMLPath: "secrets.gcp_secret_name", + EnvVar: "TMI_GCP_SECRET_NAME", + Get: func(c *Config) string { return c.Secrets.GCPSecretName }, + OmitWhenEmpty: true, + }, + { + Key: "secrets.oci_compartment_id", + Class: classificationFor("secrets.oci_compartment_id"), + Type: "string", + Description: "OCI compartment ID", + YAMLPath: "secrets.oci_compartment_id", + EnvVar: "TMI_OCI_COMPARTMENT_ID", + Get: func(c *Config) string { return c.Secrets.OCICompartmentID }, + OmitWhenEmpty: true, + }, + { + Key: "secrets.oci_vault_id", + Class: classificationFor("secrets.oci_vault_id"), + Type: "string", + Description: "OCI vault ID", + YAMLPath: "secrets.oci_vault_id", + EnvVar: "TMI_OCI_VAULT_ID", + Get: func(c *Config) string { return c.Secrets.OCIVaultID }, + OmitWhenEmpty: true, + }, + { + Key: "secrets.oci_secret_name", + Class: classificationFor("secrets.oci_secret_name"), + Type: "string", + Description: "OCI secret name", + YAMLPath: "secrets.oci_secret_name", + EnvVar: "TMI_OCI_SECRET_NAME", + Get: func(c *Config) string { return c.Secrets.OCISecretName }, + OmitWhenEmpty: true, + }, + + // --- alerting.* (bootstrap: read once at startup by + // EnsurePinnedAlertSubscription; see classification_registry.go) --- + { + Key: "alerting.enabled", + Class: classificationFor("alerting.enabled"), + Type: "bool", + Description: "Enable the operator-pinned audit alert sink webhook subscription (#395)", + YAMLPath: "alerting.enabled", + EnvVar: "TMI_ALERTING_ENABLED", + Get: func(c *Config) string { return strconv.FormatBool(c.Alerting.Enabled) }, + }, + { + Key: "alerting.webhook_url", + Class: classificationFor("alerting.webhook_url"), + Type: "string", + Description: "URL of the audit alert sink webhook endpoint (#395)", + YAMLPath: "alerting.webhook_url", + EnvVar: "TMI_ALERTING_WEBHOOK_URL", + Get: func(c *Config) string { return c.Alerting.WebhookURL }, + OmitWhenEmpty: true, + }, + { + Key: "alerting.webhook_secret", + Class: classificationFor("alerting.webhook_secret"), + Type: "string", + Description: "HMAC signing secret for the audit alert sink webhook (#395)", + YAMLPath: "alerting.webhook_secret", + EnvVar: "TMI_ALERTING_WEBHOOK_SECRET", + Get: func(c *Config) string { return c.Alerting.WebhookSecret }, + OmitWhenEmpty: true, + }, + + // --- ssrf.* (operational, security-sensitive allowlists; empty is + // fail-closed, so Default is deliberately "" rather than a wildcard). + // YAMLPath is real on every entry below (SSRFConfig/SSRFURIConfig, + // config.go): each field carries a yaml tag but no env tag, so EnvVar is + // genuinely empty — these are config-file-only settings. The bijection + // test (setting_defs_bijection_test.go) is keyed on EnvVar, not YAMLPath, + // so a real YAMLPath with no EnvVar does not register as "extra"; a + // separate test (TestSettingDefs_YAMLPathsAreReal) checks YAMLPath + // truthfully names a yaml-tagged Config field even when there's no env + // tag to match against. + // + // Static: cmd/server/main.go's buildURIValidator(cfg.SSRF.*, ...) calls + // run once at startup to build each URI validator; there is no runtime + // re-read. --- + { + Key: "ssrf.issue_uri.allowlist", + Class: withMutability(classificationFor("ssrf.issue_uri.allowlist"), MutabilityStatic), + Type: "string", + Description: "SSRF allowlist for ssrf.issue_uri (comma-separated host patterns)", + Default: "", + YAMLPath: "ssrf.issue_uri.allowlist", + Get: func(c *Config) string { return c.SSRF.IssueURI.Allowlist }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "ssrf.issue_uri.schemes", + Class: withMutability(classificationFor("ssrf.issue_uri.schemes"), MutabilityStatic), + Type: "string", + Description: "Permitted URI schemes for ssrf.issue_uri (comma-separated, e.g. https)", + Default: "", + YAMLPath: "ssrf.issue_uri.schemes", + Get: func(c *Config) string { return c.SSRF.IssueURI.Schemes }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "ssrf.document_uri.allowlist", + Class: withMutability(classificationFor("ssrf.document_uri.allowlist"), MutabilityStatic), + Type: "string", + Description: "SSRF allowlist for ssrf.document_uri (comma-separated host patterns)", + Default: "", + YAMLPath: "ssrf.document_uri.allowlist", + Get: func(c *Config) string { return c.SSRF.DocumentURI.Allowlist }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "ssrf.document_uri.schemes", + Class: withMutability(classificationFor("ssrf.document_uri.schemes"), MutabilityStatic), + Type: "string", + Description: "Permitted URI schemes for ssrf.document_uri (comma-separated, e.g. https)", + Default: "", + YAMLPath: "ssrf.document_uri.schemes", + Get: func(c *Config) string { return c.SSRF.DocumentURI.Schemes }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "ssrf.repository_uri.allowlist", + Class: withMutability(classificationFor("ssrf.repository_uri.allowlist"), MutabilityStatic), + Type: "string", + Description: "SSRF allowlist for ssrf.repository_uri (comma-separated host patterns)", + Default: "", + YAMLPath: "ssrf.repository_uri.allowlist", + Get: func(c *Config) string { return c.SSRF.RepositoryURI.Allowlist }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "ssrf.repository_uri.schemes", + Class: withMutability(classificationFor("ssrf.repository_uri.schemes"), MutabilityStatic), + Type: "string", + Description: "Permitted URI schemes for ssrf.repository_uri (comma-separated, e.g. https)", + Default: "", + YAMLPath: "ssrf.repository_uri.schemes", + Get: func(c *Config) string { return c.SSRF.RepositoryURI.Schemes }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "ssrf.timmy.allowlist", + Class: withMutability(classificationFor("ssrf.timmy.allowlist"), MutabilityStatic), + Type: "string", + Description: "SSRF allowlist for ssrf.timmy (comma-separated host patterns)", + Default: "", + YAMLPath: "ssrf.timmy.allowlist", + Get: func(c *Config) string { return c.SSRF.Timmy.Allowlist }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "ssrf.timmy.schemes", + Class: withMutability(classificationFor("ssrf.timmy.schemes"), MutabilityStatic), + Type: "string", + Description: "Permitted URI schemes for ssrf.timmy (comma-separated, e.g. https)", + Default: "", + YAMLPath: "ssrf.timmy.schemes", + Get: func(c *Config) string { return c.SSRF.Timmy.Schemes }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "ssrf.webhook.allowlist", + Class: withMutability(classificationFor("ssrf.webhook.allowlist"), MutabilityStatic), + Type: "string", + Description: "SSRF allowlist for ssrf.webhook (comma-separated host patterns)", + Default: "", + YAMLPath: "ssrf.webhook.allowlist", + Get: func(c *Config) string { return c.SSRF.Webhook.Allowlist }, + Transitional: true, + OmitWhenEmpty: true, + }, + { + Key: "ssrf.webhook.schemes", + Class: withMutability(classificationFor("ssrf.webhook.schemes"), MutabilityStatic), + Type: "string", + Description: "Permitted URI schemes for ssrf.webhook (comma-separated, e.g. https)", + Default: "", + YAMLPath: "ssrf.webhook.schemes", + Get: func(c *Config) string { return c.SSRF.Webhook.Schemes }, + Transitional: true, + OmitWhenEmpty: true, + }, + + // --- rate_limit.* (#809): seeded into system_settings but read by no + // handler — real rate limiting runs off server.disable_rate_limiting / + // server.ratelimit_public_rpm instead. Class comes from + // classificationFor, which resolves through classification_registry.go's + // exactClassifications — the table api/config_handlers.go's + // ClassificationFor(key).Visibility check actually consults. A + // SettingDef-only declaration with no exactClassifications entry does + // NOT fix GET/DELETE /admin/settings/{key} 404ing: nothing at runtime + // reads the SettingDef registry for that check yet (Phase E work). + // Declaring both here is the behavior-preserving fix — whether to delete + // them or wire them up to an actual limiter is a separate decision + // tracked on #809, not made by this change. + // + // Static: nothing reads either key at use time, so Hot would promise a + // live edit that does nothing; Static is the honest default until (if + // ever) a real reader is added. + { + Key: "rate_limit.requests_per_minute", + Class: withMutability(classificationFor("rate_limit.requests_per_minute"), MutabilityStatic), + Type: "int", + Description: "Maximum API requests per minute per user", + Default: "100", + Seeded: true, + }, + { + Key: "rate_limit.requests_per_hour", + Class: withMutability(classificationFor("rate_limit.requests_per_hour"), MutabilityStatic), + Type: "int", + Description: "Maximum API requests per hour per user", + Default: "1000", + Seeded: true, + }, + + // --- DB-only client-config keys: no Config struct field, seeded + // directly into system_settings by models.DefaultSystemSettings. See + // classification_registry.go's "DB-only client-config knobs" comment. --- + { + Key: "features.webhooks_enabled", + Class: classificationFor("features.webhooks_enabled"), + Type: "bool", + Description: "Enable webhook subscriptions", + Default: "true", + Seeded: true, + }, + { + Key: "features.websocket_enabled", + Class: classificationFor("features.websocket_enabled"), + Type: "bool", + Description: "Enable WebSocket collaboration", + Default: "true", + Seeded: true, + }, + { + Key: "websocket.max_participants", + Class: classificationFor("websocket.max_participants"), + Type: "int", + Description: "Maximum participants per collaboration session", + Default: "10", + Seeded: true, + }, + { + Key: "upload.max_file_size_mb", + Class: classificationFor("upload.max_file_size_mb"), + Type: "int", + Description: "Maximum file upload size in megabytes", + Default: "10", + Seeded: true, + }, + { + Key: "ui.default_theme", + Class: classificationFor("ui.default_theme"), + Type: "string", + Description: "Default UI theme (auto, light, dark)", + Default: "auto", + Seeded: true, + }, + + // --- Derived key: no Config struct field of its own --- + // migratable_settings.go:433-441 computes this as + // Auth.JWT.ExpirationSeconds / 60 and reports it under the same env var + // that auth.jwt.expiration_seconds also uses. YAMLPath is genuinely empty + // — there is no "session.timeout_minutes" yaml key on Config at all, only + // the computed relationship to auth.jwt.expiration_seconds's real field. + // EnvVar legitimately names TMI_JWT_EXPIRATION_SECONDS even though + // auth.jwt.expiration_seconds also claims it — the bijection test in + // setting_defs_bijection_test.go compares as sets for exactly this case. + // + // Static: no code reads "session.timeout_minutes" at runtime at all (only + // cmd/dbtool/config_export.go documents it as a "derived display value"); + // its source field, auth.jwt.expiration_seconds, is itself Static (see + // the comment on the auth.auto_promote_first_user block in + // setting_defs_auth.go). + { + Key: "session.timeout_minutes", + YAMLPath: "", // derived: no struct field of its own + EnvVar: "TMI_JWT_EXPIRATION_SECONDS", + Type: "int", + Description: "JWT token expiration in minutes", + Default: "60", + Transitional: true, + Seeded: true, + Class: withMutability(classificationFor("session.timeout_minutes"), MutabilityStatic), + Get: func(c *Config) string { return strconv.Itoa(c.Auth.JWT.ExpirationSeconds / 60) }, + OmitWhenEmpty: true, + }, +} diff --git a/internal/config/setting_defs_operational_test.go b/internal/config/setting_defs_operational_test.go new file mode 100644 index 00000000..986050c9 --- /dev/null +++ b/internal/config/setting_defs_operational_test.go @@ -0,0 +1,59 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOperationalDefs_KnownKeysAreDeclaredOperational(t *testing.T) { + // A representative sample across the operational sections. Full coverage + // is proven by the bijection tests in setting_defs_bijection_test.go. + for _, key := range []string{ + "auth.everyone_is_a_reviewer", + "auth.oauth.client_callback_allowlist", + "features.saml_enabled", + "websocket.inactivity_timeout_seconds", + "operator.name", + "administrators", + "observability.enabled", + "ssrf.webhook.allowlist", + "webhooks.allow_http_targets", + "timmy.embedding_dimension", + } { + d, ok := DefFor(key) + require.True(t, ok, "%s must be declared in the registry", key) + assert.Equal(t, CategoryOperational, d.Class.Category, "%s must be operational", key) + // Controller ruling (fix round 1, finding 3): the brief's blanket + // assert.NotEmpty(d.Default) does not hold across this sample — + // operator.name and ssrf.webhook.allowlist both have a genuinely + // empty compiled-in Default (Type "string", exempted by + // setting_def_validation.go's operational-Default rule). Default is + // only load-bearing when the key is Seeded (written into + // system_settings at database init), so check it only then — + // consistent with the Seeded-requires-Default rule added in the + // same fix round. + if d.Seeded { + assert.NotEmpty(t, d.Default, "%s is Seeded and must declare a Default", key) + } + } +} + +func TestOperationalDefs_AreTransitionalWhileConfigDelivered(t *testing.T) { + d, ok := DefFor("auth.everyone_is_a_reviewer") + require.True(t, ok) + assert.True(t, d.Transitional, "operational settings still have a config path in Phase A") + assert.NotEmpty(t, d.EnvVar) + assert.NotNil(t, d.Get) +} + +func TestOperationalDefs_DeclareMutability(t *testing.T) { + // features.saml_enabled gates SAML manager construction at startup, so a + // database edit cannot take effect without a restart. The spec makes + // Mutability load-bearing; this pins the known case. + d, ok := DefFor("features.saml_enabled") + require.True(t, ok) + assert.Equal(t, MutabilityStatic, d.Class.Mutability, + "features.saml_enabled gates startup wiring and is restart-required") +} diff --git a/internal/config/setting_defs_server.go b/internal/config/setting_defs_server.go new file mode 100644 index 00000000..133fdf9c --- /dev/null +++ b/internal/config/setting_defs_server.go @@ -0,0 +1,478 @@ +package config + +import ( + "encoding/json" + "strconv" +) + +// serverSettingDefs declares the infrastructure bootstrap settings: server, +// database (including its nested connection pool and Redis coordinates), +// logging, and observability (OpenTelemetry/Prometheus). See setting_def.go +// for the SettingDef contract and classification_registry.go for the source +// of truth on each key's ConfigClass. +var serverSettingDefs = []SettingDef{ + // --- server.* --- + { + Key: "server.port", + Class: bootstrapClass(true, VisibilityInternal, false), + Type: "string", + Description: "HTTP server port", + YAMLPath: "server.port", + EnvVar: "TMI_SERVER_PORT", + Get: func(c *Config) string { return c.Server.Port }, + }, + { + Key: "server.interface", + Class: bootstrapClass(true, VisibilityInternal, false), + Type: "string", + Description: "Network interface to bind to", + YAMLPath: "server.interface", + EnvVar: "TMI_SERVER_INTERFACE", + Get: func(c *Config) string { return c.Server.Interface }, + }, + { + Key: "server.base_url", + Class: bootstrapClass(false, VisibilityPublic, false), + Type: "string", + Description: "Public base URL for callbacks", + YAMLPath: "server.base_url", + EnvVar: "TMI_SERVER_BASE_URL", + Get: func(c *Config) string { return c.Server.BaseURL }, + OmitWhenEmpty: true, + }, + { + Key: "server.read_timeout", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "string", + Description: "HTTP read timeout", + YAMLPath: "server.read_timeout", + EnvVar: "TMI_SERVER_READ_TIMEOUT", + Get: func(c *Config) string { return c.Server.ReadTimeout.String() }, + }, + { + Key: "server.write_timeout", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "string", + Description: "HTTP write timeout", + YAMLPath: "server.write_timeout", + EnvVar: "TMI_SERVER_WRITE_TIMEOUT", + Get: func(c *Config) string { return c.Server.WriteTimeout.String() }, + }, + { + Key: "server.idle_timeout", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "string", + Description: "HTTP idle timeout", + YAMLPath: "server.idle_timeout", + EnvVar: "TMI_SERVER_IDLE_TIMEOUT", + Get: func(c *Config) string { return c.Server.IdleTimeout.String() }, + }, + { + Key: "server.tls_enabled", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "bool", + Description: "TLS enabled", + YAMLPath: "server.tls_enabled", + EnvVar: "TMI_SERVER_TLS_ENABLED", + Get: func(c *Config) string { return strconv.FormatBool(c.Server.TLSEnabled) }, + }, + // server.tls_cert_file and server.tls_key_file are NOT OmitWhenEmpty: the + // pre-registry builder omitted them based on server.tls_enabled, a + // DIFFERENT field than the one being emitted — OmitWhenEmpty can only + // test a def's own Get() output, so it cannot express that condition. + // GetMigratableSettings special-cases these two keys directly instead. + { + Key: "server.tls_cert_file", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "string", + Description: "TLS certificate file path", + YAMLPath: "server.tls_cert_file", + EnvVar: "TMI_SERVER_TLS_CERT_FILE", + Get: func(c *Config) string { return c.Server.TLSCertFile }, + }, + { + Key: "server.tls_key_file", + Class: bootstrapClass(false, VisibilityInternal, true), + Type: "string", + Description: "TLS key file path", + YAMLPath: "server.tls_key_file", + EnvVar: "TMI_SERVER_TLS_KEY_FILE", + Get: func(c *Config) string { return c.Server.TLSKeyFile }, + }, + { + Key: "server.tls_subject_name", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "string", + Description: "TLS certificate subject name", + YAMLPath: "server.tls_subject_name", + EnvVar: "TMI_SERVER_TLS_SUBJECT_NAME", + Get: func(c *Config) string { return c.Server.TLSSubjectName }, + }, + { + Key: "server.http_to_https_redirect", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "bool", + Description: "HTTP to HTTPS redirect", + YAMLPath: "server.http_to_https_redirect", + EnvVar: "TMI_SERVER_HTTP_TO_HTTPS_REDIRECT", + Get: func(c *Config) string { return strconv.FormatBool(c.Server.HTTPToHTTPSRedirect) }, + }, + { + Key: "server.trusted_proxies", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "json", + Description: "Comma-separated CIDRs/IPs for X-Forwarded-For trusted-proxy validation", + YAMLPath: "server.trusted_proxies", + EnvVar: "TMI_TRUSTED_PROXIES", + Get: func(c *Config) string { + b, err := json.Marshal(c.Server.TrustedProxies) + if err != nil { + return "[]" + } + return string(b) + }, + }, + // disable_rate_limiting, ratelimit_public_rpm and require_if_match are + // Static: cmd/server/main.go reads all three exactly once at startup — + // apiServer.SetRateLimitingDisabled / applyRateLimitConfig(ipLimiter, ...) + // / api.SetRequireIfMatch(config.Server.RequireIfMatch) — to configure + // the rate limiter and the optimistic-locking middleware. None of the + // three has a settings-service lookup at request time; a database edit + // needs a restart to take effect. + { + Key: "server.disable_rate_limiting", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "bool", + Description: "Disable all rate limiting (dev/test only)", + Default: "false", + YAMLPath: "server.disable_rate_limiting", + EnvVar: "TMI_DISABLE_RATE_LIMITING", + Get: func(c *Config) string { return strconv.FormatBool(c.Server.DisableRateLimiting) }, + Transitional: true, + }, + // server.ratelimit_public_rpm's compiled-in Config default is the Go zero + // value 0 (getDefaultConfig does not set ServerConfig.RateLimitPublicRPM); + // applyRateLimitConfig in cmd/server/main.go treats 0 as "no override, + // keep the rate limiter's own built-in default" rather than literally + // zero requests/min. The struct-tag comment's "(default: 10)" describes + // that runtime fallback, not this Default field, which must mirror the + // actual compiled-in Config value. + { + Key: "server.ratelimit_public_rpm", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "int", + Description: "Requests per minute per IP for public endpoints", + Default: "0", + YAMLPath: "server.ratelimit_public_rpm", + EnvVar: "TMI_RATELIMIT_PUBLIC_RPM", + Get: func(c *Config) string { return strconv.Itoa(c.Server.RateLimitPublicRPM) }, + Transitional: true, + }, + { + Key: "server.require_if_match", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "bool", + Description: "Return 428 when If-Match header is missing on PUT/PATCH", + Default: "false", + YAMLPath: "server.require_if_match", + EnvVar: "TMI_REQUIRE_IF_MATCH", + Get: func(c *Config) string { return strconv.FormatBool(c.Server.RequireIfMatch) }, + Transitional: true, + }, + { + Key: "server.cors.allowed_origins", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "json", + Description: "CORS allowed origins", + YAMLPath: "server.cors.allowed_origins", + EnvVar: "TMI_CORS_ALLOWED_ORIGINS", + Get: func(c *Config) string { + b, err := json.Marshal(c.Server.CORS.AllowedOrigins) + if err != nil { + return "[]" + } + return string(b) + }, + OmitWhenEmpty: true, + }, + + // --- database.* --- + { + Key: "database.url", + Class: bootstrapClass(true, VisibilityInternal, true), + Type: "string", + Description: "Database connection URL (password redacted)", + YAMLPath: "database.url", + EnvVar: "TMI_DATABASE_URL", + Get: func(c *Config) string { return sanitizeURL(c.Database.URL) }, + }, + // database.oracle_wallet_location has no entry in exactClassifications + // and is deliberately excluded from GetMigratableSettings + // (ExpectedMigratableKeysSkipped in migratable_discovery.go: "filesystem + // path used at DB connect time; cannot be DB-backed by construction"). + // It is still a real bootstrap config/env-delivered field, so it gets a + // SettingDef with the same fallback shape as other unclassified bootstrap + // keys; flagged for the wiring/validation pass. + { + Key: "database.oracle_wallet_location", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "string", + Description: "Path to Oracle wallet directory (Oracle ADB only)", + YAMLPath: "database.oracle_wallet_location", + EnvVar: "TMI_ORACLE_WALLET_LOCATION", + Get: func(c *Config) string { return c.Database.OracleWalletLocation }, + }, + { + Key: "database.connection_pool.max_open_conns", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "int", + Description: "Maximum open database connections", + YAMLPath: "database.connection_pool.max_open_conns", + EnvVar: "TMI_DB_MAX_OPEN_CONNS", + Get: func(c *Config) string { return strconv.Itoa(c.Database.ConnectionPool.MaxOpenConns) }, + }, + { + Key: "database.connection_pool.max_idle_conns", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "int", + Description: "Maximum idle database connections", + YAMLPath: "database.connection_pool.max_idle_conns", + EnvVar: "TMI_DB_MAX_IDLE_CONNS", + Get: func(c *Config) string { return strconv.Itoa(c.Database.ConnectionPool.MaxIdleConns) }, + }, + { + Key: "database.connection_pool.conn_max_lifetime", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "int", + Description: "Max connection lifetime in seconds", + YAMLPath: "database.connection_pool.conn_max_lifetime", + EnvVar: "TMI_DB_CONN_MAX_LIFETIME", + Get: func(c *Config) string { return strconv.Itoa(c.Database.ConnectionPool.ConnMaxLifetime) }, + }, + { + Key: "database.connection_pool.conn_max_idle_time", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "int", + Description: "Max connection idle time in seconds", + YAMLPath: "database.connection_pool.conn_max_idle_time", + EnvVar: "TMI_DB_CONN_MAX_IDLE_TIME", + Get: func(c *Config) string { return strconv.Itoa(c.Database.ConnectionPool.ConnMaxIdleTime) }, + }, + { + Key: "database.redis.url", + Class: bootstrapClass(false, VisibilityInternal, true), + Type: "string", + Description: "Redis connection URL (password redacted)", + YAMLPath: "database.redis.url", + EnvVar: "TMI_REDIS_URL", + Get: func(c *Config) string { return sanitizeURL(c.Database.Redis.URL) }, + OmitWhenEmpty: true, + }, + { + Key: "database.redis.host", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "string", + Description: "Redis host", + YAMLPath: "database.redis.host", + EnvVar: "TMI_REDIS_HOST", + Get: func(c *Config) string { return c.Database.Redis.Host }, + }, + { + Key: "database.redis.port", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "string", + Description: "Redis port", + YAMLPath: "database.redis.port", + EnvVar: "TMI_REDIS_PORT", + Get: func(c *Config) string { return c.Database.Redis.Port }, + }, + { + Key: "database.redis.password", + Class: bootstrapClass(false, VisibilityInternal, true), + Type: "string", + Description: "Redis password", + YAMLPath: "database.redis.password", + EnvVar: "TMI_REDIS_PASSWORD", + Get: func(c *Config) string { return c.Database.Redis.Password }, + }, + { + Key: "database.redis.db", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "int", + Description: "Redis database number", + YAMLPath: "database.redis.db", + EnvVar: "TMI_REDIS_DB", + Get: func(c *Config) string { return strconv.Itoa(c.Database.Redis.DB) }, + }, + + // --- logging.* --- + { + Key: "logging.level", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "string", + Description: "Log level", + YAMLPath: "logging.level", + EnvVar: "TMI_LOG_LEVEL", + Get: func(c *Config) string { return c.Logging.Level }, + }, + { + Key: "logging.is_dev", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "bool", + Description: "Development mode logging", + YAMLPath: "logging.is_dev", + EnvVar: "TMI_LOG_IS_DEV", + Get: func(c *Config) string { return strconv.FormatBool(c.Logging.IsDev) }, + }, + { + Key: "logging.is_test", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "bool", + Description: "Test mode logging", + YAMLPath: "logging.is_test", + EnvVar: "TMI_LOG_IS_TEST", + Get: func(c *Config) string { return strconv.FormatBool(c.Logging.IsTest) }, + }, + { + Key: "logging.log_dir", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "string", + Description: "Log directory", + YAMLPath: "logging.log_dir", + EnvVar: "TMI_LOG_DIR", + Get: func(c *Config) string { return c.Logging.LogDir }, + }, + { + Key: "logging.max_age_days", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "int", + Description: "Log max age in days", + YAMLPath: "logging.max_age_days", + EnvVar: "TMI_LOG_MAX_AGE_DAYS", + Get: func(c *Config) string { return strconv.Itoa(c.Logging.MaxAgeDays) }, + }, + { + Key: "logging.max_size_mb", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "int", + Description: "Log max size in MB", + YAMLPath: "logging.max_size_mb", + EnvVar: "TMI_LOG_MAX_SIZE_MB", + Get: func(c *Config) string { return strconv.Itoa(c.Logging.MaxSizeMB) }, + }, + { + Key: "logging.max_backups", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "int", + Description: "Log max backup count", + YAMLPath: "logging.max_backups", + EnvVar: "TMI_LOG_MAX_BACKUPS", + Get: func(c *Config) string { return strconv.Itoa(c.Logging.MaxBackups) }, + }, + { + Key: "logging.also_log_to_console", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "bool", + Description: "Also log to console", + YAMLPath: "logging.also_log_to_console", + EnvVar: "TMI_LOG_ALSO_LOG_TO_CONSOLE", + Get: func(c *Config) string { return strconv.FormatBool(c.Logging.AlsoLogToConsole) }, + }, + { + Key: "logging.cloud_error_threshold", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "int", + Description: "Cloud sink consecutive-failure threshold for one-shot Warn alarm (0 disables)", + YAMLPath: "logging.cloud_error_threshold", + EnvVar: "TMI_LOG_CLOUD_ERROR_THRESHOLD", + Get: func(c *Config) string { return strconv.Itoa(c.Logging.CloudErrorThreshold) }, + }, + { + Key: "logging.log_api_requests", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "bool", + Description: "Log API requests", + YAMLPath: "logging.log_api_requests", + EnvVar: "TMI_LOG_API_REQUESTS", + Get: func(c *Config) string { return strconv.FormatBool(c.Logging.LogAPIRequests) }, + }, + { + Key: "logging.log_api_responses", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "bool", + Description: "Log API responses", + YAMLPath: "logging.log_api_responses", + EnvVar: "TMI_LOG_API_RESPONSES", + Get: func(c *Config) string { return strconv.FormatBool(c.Logging.LogAPIResponses) }, + }, + { + Key: "logging.log_websocket_messages", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "bool", + Description: "Log WebSocket messages", + YAMLPath: "logging.log_websocket_messages", + EnvVar: "TMI_LOG_WEBSOCKET_MESSAGES", + Get: func(c *Config) string { return strconv.FormatBool(c.Logging.LogWebSocketMsg) }, + }, + { + Key: "logging.redact_auth_tokens", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "bool", + Description: "Redact auth tokens in logs", + YAMLPath: "logging.redact_auth_tokens", + EnvVar: "TMI_LOG_REDACT_AUTH_TOKENS", + Get: func(c *Config) string { return strconv.FormatBool(c.Logging.RedactAuthTokens) }, + }, + { + Key: "logging.suppress_unauthenticated_logs", + Class: bootstrapClass(false, VisibilityInternal, false), + Type: "bool", + Description: "Suppress unauthenticated request logs", + YAMLPath: "logging.suppress_unauthenticated_logs", + EnvVar: "TMI_LOG_SUPPRESS_UNAUTH_LOGS", + Get: func(c *Config) string { return strconv.FormatBool(c.Logging.SuppressUnauthenticatedLogs) }, + }, + + // --- observability.* --- + // All three are Static: cmd/server/main.go's initOTel(ctx, cfg) reads + // cfg.Observability.Enabled/SamplingRate/PrometheusPort exactly once at + // startup to build the tmiotel.Config passed to tmiotel.Setup. There is + // no runtime re-read; changing tracing/metrics configuration needs a + // restart. + { + Key: "observability.enabled", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "bool", + Description: "OpenTelemetry tracing enabled", + Default: "false", + YAMLPath: "observability.enabled", + EnvVar: "TMI_OTEL_ENABLED", + Get: func(c *Config) string { return strconv.FormatBool(c.Observability.Enabled) }, + Transitional: true, + }, + { + Key: "observability.prometheus_port", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "int", + Description: "Prometheus metrics port (0 = disabled)", + Default: "0", + YAMLPath: "observability.prometheus_port", + EnvVar: "TMI_OTEL_PROMETHEUS_PORT", + Get: func(c *Config) string { return strconv.Itoa(c.Observability.PrometheusPort) }, + Transitional: true, + }, + // Type is "float", not "string": Observability.SamplingRate is a + // float64, and mislabelling it made --export-config emit a quoted "1" + // that --import-config could not unmarshal back into the field (#791). + { + Key: "observability.sampling_rate", + Class: withMutability(operationalClass(VisibilityAdminOnly, false), MutabilityStatic), + Type: "float", + Description: "OpenTelemetry trace sampling rate (0.0–1.0)", + Default: "1", + YAMLPath: "observability.sampling_rate", + EnvVar: "TMI_OTEL_SAMPLING_RATE", + Get: func(c *Config) string { return strconv.FormatFloat(c.Observability.SamplingRate, 'f', -1, 64) }, + Transitional: true, + }, +} diff --git a/internal/config/transitional_ratchet_test.go b/internal/config/transitional_ratchet_test.go new file mode 100644 index 00000000..3efe50f1 --- /dev/null +++ b/internal/config/transitional_ratchet_test.go @@ -0,0 +1,144 @@ +package config + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" +) + +// transitionalKeys is the golden list of operational settings that still have +// a config-file or environment delivery path, pending Phase E of the config +// model redesign (docs/superpowers/specs/2026-08-22-config-model-redesign-design.md). +// +// This list may only SHRINK. Adding a key here means adding a new config/env +// path for a database-only setting, which is the thing the redesign exists to +// remove. When it reaches zero, Phase E is complete and goal 2 is enforced. +// +// Populate it in Step 2 from the test's own failure output. +var transitionalKeys = []string{ + "administrators", + "auth.auto_promote_first_user", + "auth.cookie.domain", + "auth.cookie.enabled", + "auth.cookie.secure", + "auth.everyone_is_a_reviewer", + "auth.jwt.expiration_seconds", + "auth.jwt.refresh_token_days", + "auth.jwt.session_lifetime_days", + "auth.oauth.client_callback_allowlist", + "auth.oauth_callback_url", + "auth.step_up_window_seconds", + "content_extractors.compressed_size_bytes", + "content_extractors.decompressed_size_bytes", + "content_extractors.markdown_size_bytes", + "content_extractors.part_size_bytes", + "content_extractors.per_user_concurrency_default", + "content_extractors.pptx_slides", + "content_extractors.wall_clock_budget", + "content_extractors.xlsx_cells", + "content_oauth.callback_url", + "content_sources.confluence.enabled", + "content_sources.google_drive.browser_oauth_client_id", + "content_sources.google_drive.credentials_file", + "content_sources.google_drive.enabled", + "content_sources.google_drive.picker_app_id", + "content_sources.google_drive.picker_developer_key", + "content_sources.google_drive.service_account_email", + "content_sources.google_workspace.enabled", + "content_sources.google_workspace.picker_app_id", + "content_sources.google_workspace.picker_developer_key", + "content_sources.microsoft.application_object_id", + "content_sources.microsoft.client_id", + "content_sources.microsoft.enabled", + "content_sources.microsoft.picker_origin", + "content_sources.microsoft.tenant_id", + "extraction.async_enabled", + "features.saml_enabled", + "observability.enabled", + "observability.prometheus_port", + "observability.sampling_rate", + "operator.contact", + "operator.jurisdiction", + "operator.name", + "server.disable_rate_limiting", + "server.ratelimit_public_rpm", + "server.require_if_match", + "session.timeout_minutes", + "ssrf.document_uri.allowlist", + "ssrf.document_uri.schemes", + "ssrf.issue_uri.allowlist", + "ssrf.issue_uri.schemes", + "ssrf.repository_uri.allowlist", + "ssrf.repository_uri.schemes", + "ssrf.timmy.allowlist", + "ssrf.timmy.schemes", + "ssrf.webhook.allowlist", + "ssrf.webhook.schemes", + "timmy.chunk_overlap", + "timmy.chunk_size", + "timmy.code_embedding_api_key", + "timmy.code_embedding_base_url", + "timmy.code_embedding_model", + "timmy.code_embedding_provider", + "timmy.code_retrieval_top_k", + "timmy.dump_extracted_text_to_note", + "timmy.embedding_cleanup_interval_minutes", + "timmy.embedding_dimension", + "timmy.embedding_idle_days_active", + "timmy.embedding_idle_days_closed", + "timmy.enabled", + "timmy.inactivity_timeout_seconds", + "timmy.llm_api_key", + "timmy.llm_base_url", + "timmy.llm_max_tokens", + "timmy.llm_model", + "timmy.llm_provider", + "timmy.llm_timeout_seconds", + "timmy.max_concurrent_llm_requests", + "timmy.max_conversation_history", + "timmy.max_memory_mb", + "timmy.max_messages_per_user_per_hour", + "timmy.max_sessions_per_threat_model", + "timmy.operator_system_prompt", + "timmy.query_decomposition_enabled", + "timmy.rerank_api_key", + "timmy.rerank_base_url", + "timmy.rerank_model", + "timmy.rerank_provider", + "timmy.rerank_top_k", + "timmy.text_embedding_api_key", + "timmy.text_embedding_base_url", + "timmy.text_embedding_model", + "timmy.text_embedding_provider", + "timmy.text_retrieval_top_k", + "webhooks.allow_http_targets", + "websocket.inactivity_timeout_seconds", +} + +func TestTransitionalKeys_MatchGoldenListExactly(t *testing.T) { + var actual []string + for _, d := range AllSettingDefs() { + if d.Transitional { + actual = append(actual, d.Key) + } + } + sort.Strings(actual) + + want := append([]string{}, transitionalKeys...) + sort.Strings(want) + + assert.Equal(t, want, actual, + "the transitional list may only shrink: removing a key means its config/env "+ + "path is gone (good); adding one means a new config/env path was introduced "+ + "for a database-only setting (not allowed)") +} + +func TestTransitionalKeys_AreAllOperational(t *testing.T) { + for _, d := range AllSettingDefs() { + if d.Transitional { + assert.Equal(t, CategoryOperational, d.Class.Category, + "%s is Transitional but not operational", d.Key) + } + } +}