// Package config loads Cairn's runtime configuration from the environment. // // The single static binary reads all configuration from the environment // (ADR-0012 "Deployment shape"): a Postgres DSN for metadata and an // S3-compatible endpoint for content-addressed bodies (ADR-0008). // // Governing: ADR-0012 (Backend Platform and API Shape), ADR-0008 (Storage & Content Model) package config import ( "fmt" "os" "strconv" "strings" "time" ) // Config is the fully-resolved runtime configuration. type Config struct { HTTPAddr string // DatabaseURL is the Postgres DSN holding all queryable metadata. DatabaseURL string // S3-compatible object storage for body blobs (ADR-0008). S3Endpoint string S3AccessKey string S3SecretKey string S3Bucket string S3Region string S3UseSSL bool // MaxUploadBytes caps a single streamed body; enforced incrementally // during ingest so an oversize upload is rejected mid-stream (413). MaxUploadBytes int64 // PreviewMaxBytes is the upper bound for a body to be considered for a // rich viewer; bodies above it fall back to the generic file path. PreviewMaxBytes int64 // DefaultTTL is the default artifact expiry when a surface does not set // one explicitly (ADR-0007 short default TTL). DefaultTTL time.Duration // Retention reaper (SPEC-0009 REQ "Hard Delete on Expiry via // Reference-Counted Reaper", REQ "Concurrency Safety (Expiry Reaper)"). // ReapInterval is the ticker period of the background reaper worker in // cairnd; ReapBatch bounds each phase's per-cycle work; ReapObjectGrace is // the minimum age a blobs/ object must reach before the orphan-object scan // (the no-DB-row backstop) may delete it — defense-in-depth around the brief // window between a create promoting its object and committing its blob row. ReapInterval time.Duration ReapBatch int ReapObjectGrace time.Duration // StagingLifecycleTTL sets the S3 lifecycle expiration on the staging/ // prefix so abandoned upload debris is reaped even if a process crashed // mid-upload. It is scoped to staging/ ONLY and MUST NEVER be applied to the // committed blobs/ prefix (content-addressed dedup means an object's age has // no relationship to its longest live reference — issue #93 §1, SPEC-0009). StagingLifecycleTTL time.Duration // Lexical search index (ADR-0028, SPEC-0022 REQ-2, REQ-11). SearchLanguage // is the Postgres text-search configuration to_tsvector tokenizes with; // SearchMaxBodyBytes caps one body (and one bundle member) in the index, so // the index copy stays bounded; SearchIndexWorkers is the indexer pool size. // The indexer always runs: the queue is filled by the write paths, so there // is no "indexing off" switch — only a pool the backlog drains through. SearchLanguage string SearchMaxBodyBytes int SearchIndexWorkers int // BaseURL is the public origin used to build short URLs (ADR-0005), // e.g. https://cairn.stump.wtf. BaseURL string // Rate limiting for public/ingress endpoints (SPEC-0002 REQ "Rate // Limiting"): tokens per second and bucket burst, per client IP. RatePerSecond float64 RateBurst int // DevLoginPassword is the shared secret the MVP web login accepts for any // actor id (SPEC-0001, ADR-0004). Real human auth is native OIDC against // Pocket ID (ADR-0013); this fallback is demoted to local-dev-only — it is // honored only when CAIRN_OIDC_ISSUER is unset, so a production deployment // that configures OIDC can never fall back to it. Empty disables interactive // login entirely (the deployment relies on bearer tokens only). DevLoginPassword string // SessionTTL is the lifetime of a web session and its cookies (SPEC-0001). SessionTTL time.Duration // OIDC relying-party config (ADR-0013): Cairn logs the human in directly // against Pocket ID rather than fronting itself with oauth2-proxy. OIDC is // "enabled" iff OIDCIssuer is non-empty (OIDCConfigured); the redirect URI is // always derived as BaseURL + /auth/callback, never separately configured, so // it can never drift from the public origin Pocket ID was registered against. OIDCIssuer string // e.g. https://pocket-id.stump.rocks OIDCClientID string // defaults to "cairn" OIDCClientSecret string // OIDCTrustEmail declares the OIDC issuer authoritative for its users' // emails: its email claim counts as verified whatever its email_verified // claim says. For an IdP the operator runs, whose users cannot set their // own email (Pocket ID with LDAP sync sends email_verified false unless // EMAILS_VERIFIED is set). Never set it for an IdP where a user types // their own email: a verified email links to, and claims, the user who // owns it (SPEC-0023 REQ "Users and Identities"). OIDCTrustEmail bool // GitHub human login (SPEC-0012): a second production provider beside // Pocket ID. OAuth 2.0 authorization-code — GitHub has no OIDC login — so // verification is the code exchange plus REST profile fetch, while routes, // state cookie and session establishment are shared with the OIDC flow. // Enabled iff GitHubClientID and GitHubClientSecret are both non-empty // (GitHubConfigured); the redirect URI is derived as BaseURL + // /auth/callback like OIDC's, never separately configured, so it can never // drift from the public origin the OAuth app was registered against. GitHubClientID string GitHubClientSecret string // Owned outbound subscriptions (ADR-0029 section 6, SPEC-0023 REQ "Owned // Outbound Subscriptions"). Delivery targets are rows each user or team // owns; there is no instance-wide target list. // // EncryptionKeyRaw is CAIRN_ENCRYPTION_KEY, 32 base64 bytes that seal // subscription secrets at rest (parsed by subscription.ParseKey; never // logged). Unset, subscriptions cannot be created and everything else // runs normally. EncryptionKeyRaw string // OutboundAllowHTTP is CAIRN_OUTBOUND_ALLOW_HTTP: admit http:// // subscription targets. Risky and off by default; cairn serve WARNs at // startup when it is on (REQ "Subscription Target Safety"). OutboundAllowHTTP bool // SubscriptionsPerUser and SubscriptionsPerTeam are the per-owner // ceilings (CAIRN_SUBSCRIPTIONS_PER_USER / _PER_TEAM, default 5 / 10). SubscriptionsPerUser int SubscriptionsPerTeam int // ApprovalReactions is CAIRN_APPROVAL_REACTIONS split on commas: the emoji // whose reaction is an approval (ADR-0022, SPEC-0016 EV-5). Empty means the // default class, 👍 ✅ ✔️. cmd/cairnd normalizes and validates it with // annotation.NewApprovalClass at startup, so a bad entry fails the process. ApprovalReactions []string // OAuth 2.1 authorization-server tuning (SPEC-0007, ADR-0004): // access-token lifetime (~1h default), rotating refresh-token lifetime // (30d default), and the dedicated per-IP rate limit on the OAuth // bootstrap endpoints (register/token/revoke/authorize). OAuthAccessTokenTTL time.Duration OAuthRefreshTokenTTL time.Duration OAuthRatePerSecond float64 OAuthRateBurst int // Webhook open ingress (SPEC-0005, ADR-0010, issue #84): the // anonymous-write `ANY /h/{id}` route carries its OWN dedicated // per-source-IP and per-endpoint rate limiters, distinct from the // general RatePerSecond/RateBurst above and from OAuth's — the single // most exposed surface in Cairn must never share a budget with // authenticated traffic (SPEC-0005 REQ "Rate Limiting": "per-endpoint // and per-source-IP"). HookIngressRatePerSecond float64 HookIngressRateBurst int HookEndpointRatePerSecond float64 HookEndpointRateBurst int // APITokensRaw is the unparsed CAIRN_API_TOKENS value: a comma-separated list // of `secret:[:agent|:human]` static bearer credentials, each acting // as an operator's user named by "|" or verified email // (SPEC-0023 REQ "Static API Tokens Act as an Operator's User"). Parsed // and resolved at startup; a legacy `secret:actor` entry, or one naming a // non-operator, fails boot. Empty means the bearer surface accepts no // static tokens. APITokensRaw string // OperatorsRaw is the unparsed CAIRN_OPERATORS value: comma-separated // "|" sign-in identities of the instance's operators // (SPEC-0023 REQ "Operator and User Profiles"). OperatorGroup is // CAIRN_OPERATOR_GROUP, an OIDC group whose members are operators. With // both empty the instance has no operator. Parsed at startup // (operator.Parse); a malformed entry fails boot. OperatorsRaw string OperatorGroup string // Ingest redaction (ADR-0023, SPEC-0017 RD-4, RD-7, RD-8). There is // deliberately no switch that turns scanning off. // // RedactionMaxScanBytes caps each scanned field (default 16 MiB). // RedactionOversize is what happens to a text field over the cap: "reject" // (the default) or "store_unscanned", a risky opt-in cairnd warns about at // startup. RedactionRejectTypes lists the share types whose writes are // refused rather than masked (default code,bundle). RedactionAllowlistFile // names the operator's value-only allowlist TOML; a file that does not // parse, or that names paths or commits, stops startup. RedactionMaxScanBytes int64 RedactionOversize string RedactionRejectTypes []string RedactionAllowlistFile string // DevInsecureBearerAuth, when true, makes the API trust a raw bearer token AS // the actor id with no verification. It is an INSECURE local-development // shortcut that must never be enabled in production; the default is false, so // production verifies every bearer token against APITokens. DevInsecureBearerAuth bool } // Load resolves configuration from the environment, applying defaults and // returning a descriptive error for any malformed value. func Load() (*Config, error) { c := &Config{ HTTPAddr: env("CAIRN_HTTP_ADDR", ":8080"), DatabaseURL: env("CAIRN_DATABASE_URL", os.Getenv("DATABASE_URL")), S3Endpoint: env("CAIRN_S3_ENDPOINT", "localhost:9000"), S3AccessKey: env("CAIRN_S3_ACCESS_KEY", "minioadmin"), S3SecretKey: env("CAIRN_S3_SECRET_KEY", "minioadmin"), S3Bucket: env("CAIRN_S3_BUCKET", "cairn"), S3Region: env("CAIRN_S3_REGION", "us-east-1"), BaseURL: env("CAIRN_BASE_URL", "https://cairn.stump.wtf"), DevLoginPassword: os.Getenv("CAIRN_DEV_LOGIN_PASSWORD"), APITokensRaw: os.Getenv("CAIRN_API_TOKENS"), OperatorsRaw: os.Getenv("CAIRN_OPERATORS"), OperatorGroup: strings.TrimSpace(os.Getenv("CAIRN_OPERATOR_GROUP")), OIDCIssuer: os.Getenv("CAIRN_OIDC_ISSUER"), OIDCClientID: env("CAIRN_OIDC_CLIENT_ID", "cairn"), OIDCClientSecret: os.Getenv("CAIRN_OIDC_CLIENT_SECRET"), GitHubClientID: os.Getenv("CAIRN_GITHUB_CLIENT_ID"), GitHubClientSecret: os.Getenv("CAIRN_GITHUB_CLIENT_SECRET"), EncryptionKeyRaw: os.Getenv("CAIRN_ENCRYPTION_KEY"), } // Governing: SPEC-0016 EV-5 "Approval Class and the Approval Bit". for _, raw := range strings.Split(os.Getenv("CAIRN_APPROVAL_REACTIONS"), ",") { if e := strings.TrimSpace(raw); e != "" { c.ApprovalReactions = append(c.ApprovalReactions, e) } } var err error // Governing: SPEC-0023 REQ "Owned Outbound Subscriptions", REQ // "Subscription Target Safety". if c.OutboundAllowHTTP, err = envBool("CAIRN_OUTBOUND_ALLOW_HTTP", false); err != nil { return nil, err } var perUser, perTeam int64 if perUser, err = envInt64("CAIRN_SUBSCRIPTIONS_PER_USER", 5); err != nil { return nil, err } if perTeam, err = envInt64("CAIRN_SUBSCRIPTIONS_PER_TEAM", 10); err != nil { return nil, err } if perUser < 1 || perUser > 1000 || perTeam < 1 || perTeam > 1000 { return nil, fmt.Errorf("config CAIRN_SUBSCRIPTIONS_PER_USER / CAIRN_SUBSCRIPTIONS_PER_TEAM: must be between 1 and 1000") } c.SubscriptionsPerUser, c.SubscriptionsPerTeam = int(perUser), int(perTeam) if c.DevInsecureBearerAuth, err = envBool("CAIRN_DEV_INSECURE_BEARER_AUTH", false); err != nil { return nil, err } if c.OIDCTrustEmail, err = envBool("CAIRN_OIDC_TRUST_EMAIL", false); err != nil { return nil, err } // The insecure bearer lets anyone act as anyone, delete included (audit // A21). An https base URL means a real deployment, so refuse to boot // rather than serve it (SPEC-0023 REQ "Users and Identities"). if c.DevInsecureBearerAuth && strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.BaseURL)), "https://") { return nil, fmt.Errorf("CAIRN_DEV_INSECURE_BEARER_AUTH is enabled with an https CAIRN_BASE_URL (%s): the insecure bearer trusts any token as any actor and is for local http development only", c.BaseURL) } if c.S3UseSSL, err = envBool("CAIRN_S3_USE_SSL", false); err != nil { return nil, err } if c.MaxUploadBytes, err = envInt64("CAIRN_MAX_UPLOAD_BYTES", 64<<20); err != nil { return nil, err } if c.PreviewMaxBytes, err = envInt64("CAIRN_PREVIEW_MAX_BYTES", 5<<20); err != nil { return nil, err } if c.DefaultTTL, err = envDuration("CAIRN_DEFAULT_TTL", 7*24*time.Hour); err != nil { return nil, err } if c.ReapInterval, err = envDuration("CAIRN_REAP_INTERVAL", time.Hour); err != nil { return nil, err } var reapBatch int64 if reapBatch, err = envInt64("CAIRN_REAP_BATCH", 500); err != nil { return nil, err } c.ReapBatch = int(reapBatch) if c.ReapObjectGrace, err = envDuration("CAIRN_REAP_OBJECT_GRACE", time.Hour); err != nil { return nil, err } if c.StagingLifecycleTTL, err = envDuration("CAIRN_STAGING_LIFECYCLE_TTL", 7*24*time.Hour); err != nil { return nil, err } c.SearchLanguage = env("CAIRN_SEARCH_LANGUAGE", "english") if err := validTextSearchConfig(c.SearchLanguage); err != nil { return nil, err } var searchMaxBody int64 if searchMaxBody, err = envInt64("CAIRN_SEARCH_MAX_BODY_BYTES", 256<<10); err != nil { return nil, err } if searchMaxBody <= 0 { return nil, fmt.Errorf("config CAIRN_SEARCH_MAX_BODY_BYTES: must be greater than 0") } c.SearchMaxBodyBytes = int(searchMaxBody) var searchWorkers int64 if searchWorkers, err = envInt64("CAIRN_SEARCH_INDEX_WORKERS", 2); err != nil { return nil, err } if searchWorkers <= 0 { return nil, fmt.Errorf("config CAIRN_SEARCH_INDEX_WORKERS: must be at least 1") } c.SearchIndexWorkers = int(searchWorkers) if c.RatePerSecond, err = envFloat("CAIRN_RATE_PER_SECOND", 20); err != nil { return nil, err } var burst int64 if burst, err = envInt64("CAIRN_RATE_BURST", 40); err != nil { return nil, err } c.RateBurst = int(burst) if c.SessionTTL, err = envDuration("CAIRN_SESSION_TTL", 7*24*time.Hour); err != nil { return nil, err } if c.OAuthAccessTokenTTL, err = envDuration("CAIRN_OAUTH_ACCESS_TTL", time.Hour); err != nil { return nil, err } if c.OAuthRefreshTokenTTL, err = envDuration("CAIRN_OAUTH_REFRESH_TTL", 30*24*time.Hour); err != nil { return nil, err } if c.OAuthRatePerSecond, err = envFloat("CAIRN_OAUTH_RATE_PER_SECOND", 10); err != nil { return nil, err } var oauthBurst int64 if oauthBurst, err = envInt64("CAIRN_OAUTH_RATE_BURST", 30); err != nil { return nil, err } c.OAuthRateBurst = int(oauthBurst) if c.HookIngressRatePerSecond, err = envFloat("CAIRN_HOOK_INGRESS_RATE_PER_SECOND", 5); err != nil { return nil, err } var hookIngressBurst int64 if hookIngressBurst, err = envInt64("CAIRN_HOOK_INGRESS_RATE_BURST", 20); err != nil { return nil, err } c.HookIngressRateBurst = int(hookIngressBurst) if c.HookEndpointRatePerSecond, err = envFloat("CAIRN_HOOK_ENDPOINT_RATE_PER_SECOND", 10); err != nil { return nil, err } var hookEndpointBurst int64 if hookEndpointBurst, err = envInt64("CAIRN_HOOK_ENDPOINT_RATE_BURST", 50); err != nil { return nil, err } c.HookEndpointRateBurst = int(hookEndpointBurst) if err := loadRedaction(c); err != nil { return nil, err } return c, nil } // loadRedaction resolves the CAIRN_REDACTION_* settings (ADR-0023, SPEC-0017). // A value it does not recognise is a startup error, never a silent default: // these settings decide what may be stored with a credential in it. func loadRedaction(c *Config) error { var err error if c.RedactionMaxScanBytes, err = envInt64("CAIRN_REDACTION_MAX_SCAN_BYTES", 16<<20); err != nil { return err } if c.RedactionMaxScanBytes <= 0 { return fmt.Errorf("config CAIRN_REDACTION_MAX_SCAN_BYTES: must be positive, got %d", c.RedactionMaxScanBytes) } c.RedactionOversize = env("CAIRN_REDACTION_OVERSIZE", "reject") switch c.RedactionOversize { case "reject", "store_unscanned": default: return fmt.Errorf("config CAIRN_REDACTION_OVERSIZE: %q: must be reject or store_unscanned", c.RedactionOversize) } c.RedactionRejectTypes = nil for _, raw := range strings.Split(env("CAIRN_REDACTION_REJECT_TYPES", "code,bundle"), ",") { if t := strings.ToLower(strings.TrimSpace(raw)); t != "" { c.RedactionRejectTypes = append(c.RedactionRejectTypes, t) } } c.RedactionAllowlistFile = os.Getenv("CAIRN_REDACTION_ALLOWLIST_FILE") return nil } // OIDCConfigured reports whether the OIDC relying-party settings are present — // the single gate (ADR-0013) that decides whether Cairn logs humans in via // Pocket ID (this true) or falls back to dev_login_password (this false). func (c *Config) OIDCConfigured() bool { return c.OIDCIssuer != "" } // GitHubConfigured reports whether the GitHub login provider settings are // present (SPEC-0012). The provider joins the registry — and the login page // renders its button — only when this is true; an unconfigured provider is // indistinguishable from an unknown one (404) at the routes. func (c *Config) GitHubConfigured() bool { return c.GitHubClientID != "" && c.GitHubClientSecret != "" } func envFloat(key string, def float64) (float64, error) { v, ok := os.LookupEnv(key) if !ok || v == "" { return def, nil } f, err := strconv.ParseFloat(v, 64) if err != nil { return 0, fmt.Errorf("config %s: %w", key, err) } return f, nil } func env(key, def string) string { if v, ok := os.LookupEnv(key); ok && v != "" { return v } return def } func envBool(key string, def bool) (bool, error) { v, ok := os.LookupEnv(key) if !ok || v == "" { return def, nil } b, err := strconv.ParseBool(v) if err != nil { return false, fmt.Errorf("config %s: %w", key, err) } return b, nil } func envInt64(key string, def int64) (int64, error) { v, ok := os.LookupEnv(key) if !ok || v == "" { return def, nil } n, err := strconv.ParseInt(v, 10, 64) if err != nil { return 0, fmt.Errorf("config %s: %w", key, err) } return n, nil } func envDuration(key string, def time.Duration) (time.Duration, error) { v, ok := os.LookupEnv(key) if !ok || v == "" { return def, nil } d, err := time.ParseDuration(v) if err != nil { return 0, fmt.Errorf("config %s: %w", key, err) } return d, nil } // validTextSearchConfig rejects a CAIRN_SEARCH_LANGUAGE that cannot name a // Postgres text-search configuration before the server boots, rather than // letting every artifact fail to index and back off forever. The value reaches // to_tsvector as a bound regconfig parameter, never as concatenated SQL, so // this is a usability bound and not the injection guard. func validTextSearchConfig(name string) error { if name == "" || len(name) > 63 { return fmt.Errorf("config CAIRN_SEARCH_LANGUAGE: must be a non-empty Postgres text-search configuration name") } for i, r := range name { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z': case (r >= '0' && r <= '9' || r == '_' || r == '.' || r == '-') && i > 0: default: return fmt.Errorf("config CAIRN_SEARCH_LANGUAGE: %q is not a valid configuration name", name) } } return nil }