Ctrl K
ComunidadGuías…

internal/config/config.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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
}