internal/config/config.go
Rama main · réplica de lectura del repositorio original.
// 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:<user>[:agent|:human]` static bearer credentials, each acting
// as an operator's user named by "<issuer>|<subject>" 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
// "<issuer>|<subject>" 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
}