Operator decision: backend env hard-coding a single guild/channel was
wrong because (a) one Center can host many guilds and (b) one guild
can have many announce channels for different purposes. The
proposing agent now chooses where this topic's lifecycle events go,
passed as create-topic params and stored on the topic row.
Schema migration 002:
- ALTER topics ADD announce_guild_base_url VARCHAR(255) NULL,
announce_channel_id VARCHAR(64) NULL.
- Both nullable; one-of-two is rejected at POST time; both null =
topic creator opted out of broadcasts (announcer skips with log).
handlers/topics.go: createTopicBody adds announce_guild_base_url +
announce_channel_id; validates both-or-neither.
fabric/announce.go: rewritten signature. NewAnnouncer takes only
the system api key. PostTopicAnnouncement + PostLifecycleEvent take
a Target {GuildBaseURL, ChannelID} per call. Zero-value Target -> skip.
orchestrator/ticker.go: new helper topicTarget(topic) extracts the
target from the topic row; all broadcasts route through it.
verdict.go: same per-topic target extraction at completion.
config: removed FabricGuildBaseURL, FabricAnnounceChannelID,
FabricBotBearerToken from the Config struct + env reads.
FabricSystemAPIKey env renamed to DIALECTIC_FABRIC_SYSTEM_API_KEY
to disambiguate from the Fabric backend's own
FABRIC_BACKEND_GUILD_SYSTEM_API_KEY (operator: paste the same value
into both - one says "I am the system caller", the other says "I
accept this caller as system").
FABRIC_BOT_BEARER_TOKEN is gone entirely. The upgraded Guild
ApiKeyGuard accepts x-fabric-system-key alone for announce posts;
no per-user Bearer needed. Pairs with the matching change on
nav/Fabric.Backend.Guild commit 985b06a.
179 lines
5.7 KiB
Go
179 lines
5.7 KiB
Go
// Package config loads runtime configuration from environment variables.
|
|
//
|
|
// Conventions:
|
|
// - 12-factor: every config knob is an env var; no config files.
|
|
// - Sensible dev defaults for local docker-compose; prod sets via env.
|
|
// - Sensitive values (DB password, system api key) are *required* in
|
|
// prod; LoadFromEnv() fails fast if absent and ENV_MODE != "dev".
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
// "dev" | "prod". Dev relaxes required-field checks and enables a
|
|
// dev-mode auth bypass token. Prod requires every sensitive field.
|
|
Mode string
|
|
|
|
// HTTP server bind. e.g. "0.0.0.0:8090".
|
|
HTTPAddr string
|
|
|
|
// CORS allowed origins (comma-separated; "*" allowed only in dev).
|
|
CORSAllowOrigins []string
|
|
|
|
// MySQL DSN parts.
|
|
DBHost string
|
|
DBPort string
|
|
DBName string
|
|
DBUser string
|
|
DBPassword string
|
|
|
|
// Auth.
|
|
//
|
|
// SystemAPIKey: Phase-1 system key for posting to announce channels
|
|
// in Fabric. Mirrored here so Dialectic backend itself can post topic
|
|
// announcements via Fabric's POST /channels/:id/messages with
|
|
// x-fabric-system-key header.
|
|
//
|
|
// AgentAPIKeyPepper: HMAC pepper for hashing agent API keys at rest
|
|
// (we store sha256(pepper || raw) not the raw key). Rotating the
|
|
// pepper invalidates all keys — that's intentional, an emergency
|
|
// kill switch.
|
|
//
|
|
// OIDCDevBypassToken: dev-mode only. If set AND Mode == "dev", a
|
|
// browser request with header `x-dev-bypass: <token>` bypasses OIDC
|
|
// and is treated as user "dev-operator" with role "dialectic-admin".
|
|
// Prod ignores this even if set.
|
|
SystemAPIKey string
|
|
AgentAPIKeyPepper string
|
|
OIDCDevBypassToken string
|
|
|
|
// DialecticAdminAPIKey gates POST /api/admin/agent-keys (raw key
|
|
// minting). Held on the operator side only — kept on the openclaw
|
|
// host at /root/.openclaw/system-secrets/dialectic-admin-key for
|
|
// `dialectic-ctrl` script to read. Empty in env → admin endpoint
|
|
// fully closed.
|
|
DialecticAdminAPIKey string
|
|
|
|
// OIDC issuer URL (Keycloak realm endpoint). e.g.
|
|
// https://auth.hangman-lab.top/realms/hangman-lab
|
|
// Phase 2C ships this as configured-but-not-verified; Phase 4 wires
|
|
// real JWKS validation.
|
|
OIDCIssuer string
|
|
OIDCClientID string
|
|
|
|
// Fabric announce coupling.
|
|
//
|
|
// As of Phase 3.5: only the system api key stays in env. Guild base
|
|
// URL + announce channel ID are PER-TOPIC, supplied by the proposing
|
|
// agent at create time and stored on the topic row. Different topics
|
|
// can broadcast to different guilds/channels from the same backend.
|
|
//
|
|
// FabricSystemAPIKey: header value for x-fabric-system-key when
|
|
// POSTing to announce channels. Must match Fabric.Backend.Guild's
|
|
// FABRIC_BACKEND_GUILD_SYSTEM_API_KEY env. Empty → announcer becomes
|
|
// a no-op (logs intent, skips post — useful for dev / opted-out
|
|
// environments).
|
|
FabricSystemAPIKey string
|
|
|
|
// (removed Phase 3.5: FabricGuildBaseURL, FabricAnnounceChannelID,
|
|
// FabricBotBearerToken — guild/channel moved to per-topic config;
|
|
// bot bearer obsolete since Guild's ApiKeyGuard now accepts system
|
|
// key alone for announce posts.)
|
|
|
|
// Orchestrator tick interval. 0 / unset → default 15s.
|
|
OrchestratorTickInterval time.Duration
|
|
}
|
|
|
|
func LoadFromEnv() (*Config, error) {
|
|
c := &Config{
|
|
Mode: getenv("ENV_MODE", "dev"),
|
|
HTTPAddr: getenv("HTTP_ADDR", "0.0.0.0:8090"),
|
|
CORSAllowOrigins: splitCSV(getenv("CORS_ALLOW_ORIGINS", "*")),
|
|
DBHost: getenv("DB_HOST", "127.0.0.1"),
|
|
DBPort: getenv("DB_PORT", "3306"),
|
|
DBName: getenv("DB_NAME", "dialectic"),
|
|
DBUser: getenv("DB_USER", "dialectic"),
|
|
DBPassword: os.Getenv("DB_PASSWORD"),
|
|
SystemAPIKey: os.Getenv("SYSTEM_API_KEY"),
|
|
AgentAPIKeyPepper: os.Getenv("AGENT_API_KEY_PEPPER"),
|
|
OIDCDevBypassToken: os.Getenv("OIDC_DEV_BYPASS_TOKEN"),
|
|
DialecticAdminAPIKey: os.Getenv("DIALECTIC_ADMIN_API_KEY"),
|
|
OIDCIssuer: os.Getenv("OIDC_ISSUER"),
|
|
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
|
FabricSystemAPIKey: os.Getenv("DIALECTIC_FABRIC_SYSTEM_API_KEY"),
|
|
}
|
|
if d := os.Getenv("ORCHESTRATOR_TICK_INTERVAL"); d != "" {
|
|
if parsed, err := time.ParseDuration(d); err == nil {
|
|
c.OrchestratorTickInterval = parsed
|
|
}
|
|
}
|
|
|
|
if c.Mode != "dev" && c.Mode != "prod" {
|
|
return nil, fmt.Errorf("ENV_MODE must be dev|prod, got %q", c.Mode)
|
|
}
|
|
|
|
if c.Mode == "prod" {
|
|
var missing []string
|
|
if c.DBPassword == "" {
|
|
missing = append(missing, "DB_PASSWORD")
|
|
}
|
|
if c.AgentAPIKeyPepper == "" {
|
|
missing = append(missing, "AGENT_API_KEY_PEPPER")
|
|
}
|
|
if c.OIDCIssuer == "" {
|
|
missing = append(missing, "OIDC_ISSUER")
|
|
}
|
|
if c.OIDCClientID == "" {
|
|
missing = append(missing, "OIDC_CLIENT_ID")
|
|
}
|
|
if len(missing) > 0 {
|
|
return nil, fmt.Errorf("prod mode requires env: %s", strings.Join(missing, ", "))
|
|
}
|
|
// In prod, "*" CORS is never accepted.
|
|
for _, o := range c.CORSAllowOrigins {
|
|
if o == "*" {
|
|
return nil, fmt.Errorf("prod mode forbids CORS_ALLOW_ORIGINS='*'")
|
|
}
|
|
}
|
|
}
|
|
|
|
return c, nil
|
|
}
|
|
|
|
func (c *Config) IsDev() bool { return c.Mode == "dev" }
|
|
|
|
func (c *Config) DSN() string {
|
|
// MySQL DSN: user:pass@tcp(host:port)/dbname?params
|
|
return fmt.Sprintf(
|
|
"%s:%s@tcp(%s:%s)/%s?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci",
|
|
c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName,
|
|
)
|
|
}
|
|
|
|
func getenv(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func splitCSV(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|