From 72d67709d1eaeb109b814332f039e842fae4a87c Mon Sep 17 00:00:00 2001 From: Bretton Date: Sun, 26 Jul 2026 16:44:51 -0700 Subject: [PATCH] refactor(server): bound HTTP and pool, extract internal/config, split cmd/server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the p1 resilience gap in the backlog (2026-07-22-no-db-pool-limits-no-http-timeouts). The HTTP server was constructed with only Addr and Handler, so all four net/http timeouts were zero — which means "no deadline". A client that opened a connection and dribbled request headers held a goroutine and a file descriptor indefinitely; enough of them exhaust the process without a single complete request ever arriving. Separately, sql.Open was called with no pool configuration at all, and database/sql defaults MaxOpenConns to unlimited, so a traffic spike could open connections until PostgreSQL's max_connections was exhausted — locking out psql and the cmd/ maintenance tools along with the AppView. Both are now bounded and env-tunable. Query time is bounded server-side via statement_timeout injected into the DSN rather than per-query context deadlines: lib/pq does send a CancelRequest on context cancellation, but that path needs a second connection, races the query finishing, and does nothing if the client dies outright. Schema migrations deliberately run on a separate connection with statement_timeout stripped, since a CREATE INDEX killed halfway is worse than a slow one. main() was 1090 lines with 30 inline os.Getenv calls and no config struct. Configuration now lives in internal/config, which applies defaults, rejects malformed values, and enforces the requirements that differ between dev and production — reporting every problem at once so a misconfigured deployment is fixed in one pass instead of one restart per mistake. main() is now ~240 lines and returns an error rather than calling log.Fatal, which is what makes the deferred cleanup reachable: os.Exit skips defers, so a fatal call partway through startup abandoned the database pool and discarded the buffered OpenTelemetry spans describing the failure. Changes: - Set ReadHeaderTimeout, ReadTimeout, WriteTimeout, IdleTimeout (cmd/server/httpserver.go) - Configure the pool: MaxOpenConns, MaxIdleConns, ConnMaxLifetime, ConnMaxIdleTime (cmd/server/database.go) - Inject statement_timeout into the app DSN; strip it for migrations (internal/config/dsn.go) - Add internal/config with Load/Validate and per-subsystem config structs - Split cmd/server into main, wiring, routes, consumers, database, httpserver, jobs, health, pds - Embed goose migrations (internal/db/migrations/embed.go), removing the working-directory dependency and the Dockerfile's migrations COPY - Drain background work concurrently with the listener rather than after it, so a slow in-flight request cannot consume the whole shutdown budget and leave Jetstream cursors unflushed; drain on the listener-failure path too, and report shutdown failures through the exit code - Bound each background job cycle and recover per cycle rather than per goroutine, so neither a panic nor a hang can silently kill the job permanently; run a cycle at startup instead of waiting out the first tick - Resolve the OAuth session store once at boot and fail loudly if absent, rather than turning the cleanup job into a silent hourly no-op - Add a context timeout to the instance PDS login, which used http.DefaultClient and could hang the boot forever - Document the new HTTP_* and DB_* variables in the env examples Production now fails closed on OAUTH_SEAL_SECRET (base64, 32 bytes), CURSOR_SECRET (rejects the documented CHANGE_ME placeholder), JETSTREAM_FEEDS, APPVIEW_PUBLIC_URL and PDS_URL (must not be loopback), a non-DID INSTANCE_DID, SKIP_DID_WEB_VERIFICATION, and a zero value for any HTTP timeout. The current docker-compose.prod.yml and .env.prod.example satisfy all of these; a live .env.prod with a short CURSOR_SECRET or a malformed OAUTH_SEAL_SECRET will refuse to boot. Verified: make test-all green across all three stages, make fmt-check clean, go vet clean, race detector clean on the changed packages. Co-Authored-By: Claude Opus 5 (1M context) --- .env.dev.example | 9 + .env.prod.example | 55 + Dockerfile | 7 +- PROJECT_STRUCTURE.md | 83 +- cmd/server/consumers.go | 207 +++ cmd/server/database.go | 109 ++ cmd/server/health.go | 128 ++ cmd/server/{main_test.go => health_test.go} | 3 +- cmd/server/httpserver.go | 29 + cmd/server/httpserver_test.go | 106 ++ cmd/server/jobs.go | 173 +++ cmd/server/jobs_test.go | 231 +++ cmd/server/main.go | 1402 +++---------------- cmd/server/pds.go | 91 ++ cmd/server/routes.go | 173 +++ cmd/server/wiring.go | 516 +++++++ internal/config/config.go | 650 +++++++++ internal/config/config_test.go | 967 +++++++++++++ internal/config/dsn.go | 186 +++ internal/config/env.go | 97 ++ internal/config/testing.go | 47 + internal/db/migrations/embed.go | 25 + 22 files changed, 4059 insertions(+), 1235 deletions(-) create mode 100644 cmd/server/consumers.go create mode 100644 cmd/server/database.go create mode 100644 cmd/server/health.go rename cmd/server/{main_test.go => health_test.go} (99%) create mode 100644 cmd/server/httpserver.go create mode 100644 cmd/server/httpserver_test.go create mode 100644 cmd/server/jobs.go create mode 100644 cmd/server/jobs_test.go create mode 100644 cmd/server/pds.go create mode 100644 cmd/server/routes.go create mode 100644 cmd/server/wiring.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/config/dsn.go create mode 100644 internal/config/env.go create mode 100644 internal/config/testing.go create mode 100644 internal/db/migrations/embed.go diff --git a/.env.dev.example b/.env.dev.example index cbba193..f1e6623 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -101,10 +101,19 @@ LOG_LEVEL=debug LOG_ENABLED=true # Security settings (ONLY for local dev - set to false in production!) +# IS_DEV_ENV=true is what makes SKIP_DID_WEB_VERIFICATION acceptable: with it +# false, the server refuses to start while did:web verification is disabled, and +# it additionally requires OAUTH_SEAL_SECRET, CURSOR_SECRET, JETSTREAM_FEEDS, +# APPVIEW_PUBLIC_URL, and a non-localhost PDS_URL. +# AUTH_SKIP_VERIFY and HS256_ISSUERS are read by no Go code in this repository; +# they are inert and nothing gates them. SKIP_DID_WEB_VERIFICATION=true AUTH_SKIP_VERIFY=true HS256_ISSUERS=http://localhost:3001 +# HTTP timeouts and database pool sizing use safe defaults in dev and rarely +# need overriding here. See .env.prod.example for the full list (HTTP_*, DB_*). + # ============================================================================= # Image Proxy Configuration # ============================================================================= diff --git a/.env.prod.example b/.env.prod.example index 9e50d7c..4740c4b 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -182,8 +182,63 @@ IS_DEV_ENV=false # Skip did:web domain verification (DEVELOPMENT ONLY!) # MUST be false in production to prevent domain spoofing +# The server refuses to start if this is true while IS_DEV_ENV=false. SKIP_DID_WEB_VERIFICATION=false +# ----------------------------------------------------------------------------- +# HTTP server timeouts (all optional; defaults shown) +# ----------------------------------------------------------------------------- +# A zero timeout means "no deadline", so these are never left unset. The +# defaults are deliberately generous — the goal is a bound, not a tight one. +# +# HTTP_READ_HEADER_TIMEOUT is the slowloris defence: it caps how long a client +# may take to finish sending request headers. Without it, a client that dribbles +# headers one byte at a time holds a goroutine and a file descriptor forever. +# HTTP_READ_HEADER_TIMEOUT=10s +# +# Time allowed to read the full request (headers + body). +# HTTP_READ_TIMEOUT=30s +# +# Time from end-of-headers to end-of-response. Must comfortably exceed the +# slowest handler — the image proxy can spend all of +# IMAGE_PROXY_FETCH_TIMEOUT_SECONDS pulling a source image from a remote PDS +# before it writes a byte. +# HTTP_WRITE_TIMEOUT=120s +# +# How long an idle keep-alive connection is held open. +# HTTP_IDLE_TIMEOUT=120s +# +# Budget for graceful shutdown: draining in-flight requests and flushing +# Jetstream consumer cursors. +# HTTP_SHUTDOWN_TIMEOUT=30s + +# ----------------------------------------------------------------------------- +# Database connection pool (all optional; defaults shown) +# ----------------------------------------------------------------------------- +# PostgreSQL ships with max_connections=100. Keep DB_MAX_OPEN_CONNS well below +# that so psql and the cmd/ maintenance tools can still connect under load — +# database/sql's own default is unlimited, which can exhaust the server. +# DB_MAX_OPEN_CONNS=25 +# +# Idle connections retained for reuse. Defaults to DB_MAX_OPEN_CONNS; the +# database/sql default of 2 makes the pool reconnect under exactly the +# concurrency it exists to absorb. Must not exceed DB_MAX_OPEN_CONNS. +# DB_MAX_IDLE_CONNS=25 +# +# Retire connections after this age so a PostgreSQL restart or failover does +# not strand the pool on dead connections. +# DB_CONN_MAX_LIFETIME=30m +# +# Release connections idle this long, returning server-side memory after a spike. +# DB_CONN_MAX_IDLE_TIME=5m +# +# Server-side cap on any single query, enforced by PostgreSQL. lib/pq does send +# a cancel request when a query's context is cancelled, but that path needs a +# second connection, races the query finishing, and does nothing if the client +# dies outright; this is enforced by the server itself. Schema migrations +# deliberately run on a separate connection without this bound. +# DB_STATEMENT_TIMEOUT=30s + # ============================================================================= # Image Proxy Configuration # ============================================================================= diff --git a/Dockerfile b/Dockerfile index 5645972..ec57d37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,12 +39,11 @@ RUN addgroup -g 1000 coves && \ WORKDIR /app # Copy binary from builder +# Migrations are embedded in the binary (see internal/db/migrations/embed.go), +# so there is no migrations directory to copy. Note the static assets below are +# still resolved relative to WORKDIR. COPY --from=builder /build/coves-server /app/coves-server -# Copy migrations (needed for goose) -# Must maintain path structure as app looks for internal/db/migrations -COPY --from=builder /build/internal/db/migrations /app/internal/db/migrations - # Copy static assets (images, etc. for the web interface) COPY --from=builder /build/static /app/static diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index f1bd5b8..2519904 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -11,37 +11,78 @@ Coves/ ├── ATPROTO_GUIDE.md # Comprehensive AT Protocol implementation guide ├── PROJECT_STRUCTURE.md # This file - project structure overview ├── LICENSE # Project license -├── README.md # Project overview and setup instructions ├── go.mod # Go module definition ├── go.sum # Go module checksums │ ├── cmd/ # Application entrypoints +│ ├── server/ # The AppView binary (see "Server startup" below) +│ ├── backfill-profiles/ # One-off maintenance: backfill actor profiles +│ ├── reindex-votes/ # One-off maintenance: rebuild vote counts +│ ├── tools/ # generate-oauth-key +│ ├── validate-lexicon/ # Lexicon schema validation +│ └── validate-live/ # Validation against a live instance +│ ├── internal/ # Private application code -│ ├── xrpc/ † # XRPC handlers (atProto API layer) -│ ├── api/ # Traditional HTTP endpoints (minimal) +│ ├── api/ # HTTP layer +│ │ ├── handlers/ # XRPC request handlers, one package per domain +│ │ ├── middleware/ 🔒 # Auth, API keys, rate limiting +│ │ ├── routes/ # Route registration per domain +│ │ └── xrpc/ # Shared XRPC error response shape +│ ├── config/ 🔒 # Environment loading + production validation │ ├── core/ # Business logic and domain models -│ ├── atproto/ # atProto-specific implementations -│ └── config/ † # Configuration management -│ -├── db/ # Database layer -│ ├── appview/ † # AppView PostgreSQL queries -│ ├── postgres/ # Legacy/non-atProto database operations -│ ├── migrations/ # Database migrations -│ ├── local_dev_db_compose/ # Local development database -│ └── test_db_compose/ # Test database setup -│ -├── pkg/ # Public packages (can be imported by external projects) -├── data/ # Runtime data storage -│ └── carstore/ 🔒 # CAR file storage directory +│ │ └── errors/ # Error types shared by every domain package +│ ├── atproto/ # atProto implementations +│ │ ├── identity/ # DID + handle resolution, with cache +│ │ ├── jetstream/ # Firehose consumers, cursors, dead letters +│ │ ├── lexicon/ # Generated lexicon types +│ │ ├── oauth/ 🔒 # atProto OAuth client, sealed session tokens +│ │ └── pds/ # PDS client (write-forward) +│ ├── db/ +│ │ ├── postgres/ # AppView repositories (parameterized queries) +│ │ └── migrations/ # goose migrations, embedded into the binary +│ ├── observability/ # Optional OpenTelemetry tracing +│ ├── validation/ # Lexicon-level input validation +│ └── web/ # Server-rendered pages (landing, delete account) │ +├── static/ # Static web assets ├── scripts/ # Development and deployment scripts -├── tests/ # Integration and e2e tests -├── docs/ † # Additional documentation -├── local_dev_data/ # Local development data -├── test_db_data/ # Test database seed data -└── build/ † # Build artifacts +├── tests/ # Integration and e2e tests (require live infra) +├── docs/ # Additional documentation +└── aggregators/ # Aggregator bot examples ``` +## Server startup + +`cmd/server` is split by concern rather than being one long `main`: + +| File | Responsibility | +|------|----------------| +| `main.go` | `run()` orchestration, serve loop, graceful shutdown | +| `wiring.go` | Builds every repository, service, and middleware in dependency order | +| `routes.go` | Router construction and route registration | +| `consumers.go` | Jetstream consumer wiring, feed topology, dead letter redriver | +| `database.go` | Migrations and the application connection pool | +| `httpserver.go` | HTTP listener with all timeouts set | +| `jobs.go` | Background jobs (OAuth cleanup, aggregator token refresh) | +| `health.go` | `/health`, `/xrpc/_health`, `/health/consumers` | +| `pds.go` | Instance PDS authentication | + +Server wiring reads configuration through `internal/config`. `config.Load()` +applies defaults, rejects malformed values, and enforces the requirements that +differ between dev and production — so a misconfigured deployment fails at +startup with every problem listed at once, rather than at first use. + +A few self-contained subsystems keep their own loaders rather than routing +through it: `internal/observability`, `internal/core/imageproxy`, +`internal/api/handlers/wellknown`, and `internal/core/posts` (the latter reads +`TRUSTED_AGGREGATOR_DIDS` / `KAGI_AGGREGATOR_DID` per call, which is worth +folding into `InstanceConfig` at some point). + +Migrations are embedded into the binary (`internal/db/migrations/embed.go`), so +the container image no longer has to reproduce the repository layout around it. +Note the working directory still matters for static assets, which +`internal/api/routes/web.go` serves from a relative path. + ## Development Guidelines diff --git a/cmd/server/consumers.go b/cmd/server/consumers.go new file mode 100644 index 0000000..d09c7d0 --- /dev/null +++ b/cmd/server/consumers.go @@ -0,0 +1,207 @@ +package main + +import ( + "Coves/internal/atproto/jetstream" + "context" + "errors" + "fmt" + "log/slog" + "sync" +) + +// consumerSet is the collection of Jetstream consumers started for this +// process, kept so /health/consumers can report on them and so shutdown can +// drain them. +type consumerSet struct { + connectors []*jetstream.Connector +} + +// feedConsumer pairs a consumer's stable name with its handler. The name keys +// the consumer's persisted cursor and dead letter rows, so it must not change +// between releases. +type feedConsumer struct { + name string + handler jetstream.EventHandler +} + +// startConsumers wires every Jetstream consumer, validates the feed topology, +// and starts one connector per (feed, consumer) pair plus the dead letter +// redriver. +// +// All consumers share ctx so a single cancellation drains them: read loops +// unblock, an interrupted in-flight event is abandoned without advancing its +// cursor (it replays idempotently on the next boot), and the final cursor is +// flushed. +func startConsumers(ctx context.Context, wg *sync.WaitGroup, app *application) (*consumerSet, error) { + feeds, err := jetstream.ParseFeeds(app.cfg.Jetstream.FeedsSpec) + if err != nil { + return nil, fmt.Errorf("invalid JETSTREAM_FEEDS: %w", err) + } + warnIfNoPrimaryFeed(feeds) + + consumers := app.registerFeedConsumers() + + // FAIL CLOSED: with more than one feed, every consumer must be + // rev-gated. An ungated consumer would apply the lagging feed's stale + // copies — zombie deletes, regressed edits — which is silent data + // corruption, not a degraded mode. A forgotten WithXRevGate option must + // stop the boot, not ship the bug. + if len(feeds) > 1 { + for _, consumer := range consumers { + gated, ok := consumer.handler.(interface{ RevGated() bool }) + if !ok || !gated.RevGated() { + return nil, fmt.Errorf("consumer %q is not rev-gated but %d Jetstream feeds are configured; "+ + "multi-feed operation requires every consumer to carry the rev gate (see rev_gate.go)", + consumer.name, len(feeds)) + } + } + } + + set := &consumerSet{} + handlers := make(map[string]jetstream.EventHandler, len(consumers)*len(feeds)) + + // Consumer names on the primary ("bsky") feed stay bare so live cursors + // carry over from the single-feed era; other feeds get "@" + // names, which start cursor-less and live-tail. Rev-gating makes the + // cross-feed overlap safe — expect "rev-gate: skipped stale" lines for the + // lagging feed's copies. That is the system working, not an error. + for _, feed := range feeds { + for _, consumer := range consumers { + collections, err := jetstream.WantedCollections(consumer.name) + if err != nil { + return nil, fmt.Errorf("resolving wantedCollections for consumer %s: %w", consumer.name, err) + } + wsURL, err := jetstream.SubscribeURL(feed.BaseURL, collections) + if err != nil { + return nil, fmt.Errorf("building Jetstream URL for consumer %s on feed %s: %w", + consumer.name, feed.Key, err) + } + + name := jetstream.FeedConsumerName(consumer.name, feed.Key) + handlers[name] = consumer.handler + set.start(ctx, wg, app, name, wsURL, consumer.handler) + } + slog.Info("started Jetstream consumers on feed", + "consumers", len(consumers), "feed", feed.Key, "url", feed.BaseURL) + } + + // The redriver replays events that failed every in-line retry against the + // same handlers, so a transient failure (a Postgres blip, say) self-heals + // instead of silently losing the event. + redriver := jetstream.NewDeadLetterRedriver(app.jetstreamState, handlers) + wg.Add(1) + go func() { + defer wg.Done() + redriver.Run(ctx) + }() + slog.Info("started Jetstream dead letter redriver") + + return set, nil +} + +// start launches one connector with cursor persistence, retry plus dead +// letter, and graceful shutdown. +func (s *consumerSet) start(ctx context.Context, wg *sync.WaitGroup, app *application, name, wsURL string, handler jetstream.EventHandler) { + connector := jetstream.NewConnector(name, wsURL, handler, + jetstream.WithCursorStore(app.jetstreamState), + jetstream.WithDeadLetterWriter(app.jetstreamState), + ) + s.connectors = append(s.connectors, connector) + + wg.Add(1) + go func() { + defer wg.Done() + if err := connector.Start(ctx); err != nil && !errors.Is(err, context.Canceled) { + slog.Error("Jetstream consumer stopped", "consumer", name, "error", err) + } + }() +} + +// registerFeedConsumers builds every consumer, in a fixed order. Each runs +// once per configured feed, with its collection filters appended by +// jetstream.WantedCollections. +func (a *application) registerFeedConsumers() []feedConsumer { + var consumers []feedConsumer + + // Users: actor profiles and actor blocks. + // + // A trusted bridge hosts many virtual repos, and its profile records may + // be the first time Coves sees those identities. Relay scheduling can + // deliver profiles and posts in either order, so the user, post, and + // comment consumers share one provenance gate. + userOpts := []jetstream.ConsumerOption{ + jetstream.WithUserBridgeTrust(a.bridgeTrust), + jetstream.WithUserRevGate(a.revGate), + jetstream.WithUserBlockRepo(a.userBlockRepo), + } + // Statically typed rather than asserted at runtime: a type assertion here + // would silently disable handle sync if the store's method set ever + // drifted, leaving OAuth sessions pinned to stale handles with no error. + // The compiler now catches that instead. + if sessionUpdater := a.oauthStore.UnwrapPostgresStore(); sessionUpdater != nil { + userOpts = append(userOpts, jetstream.WithSessionHandleUpdater(sessionUpdater)) + slog.Info("OAuth session handle sync enabled for identity changes") + } + consumers = append(consumers, feedConsumer{ + name: jetstream.ConsumerUsers, + handler: jetstream.NewUserEventConsumer(a.userService, a.identityResolver, userOpts...), + }) + + // Communities: profiles (in the community's own repo), plus subscriptions + // and community blocks (in the subscribing user's repo). The identity + // resolver supplies PLC handle resolution, which is the source of truth. + if a.cfg.Instance.SkipDIDWebVerification { + slog.Warn("did:web domain verification is DISABLED; this must never be set in production") + } + consumers = append(consumers, feedConsumer{ + name: jetstream.ConsumerCommunities, + handler: jetstream.NewCommunityEventConsumer( + a.communityRepo, a.cfg.Instance.DID, a.cfg.Instance.SkipDIDWebVerification, + a.identityResolver, jetstream.WithCommunityRevGate(a.revGate)), + }) + + // Posts created in community repositories. + consumers = append(consumers, feedConsumer{ + name: jetstream.ConsumerPosts, + handler: jetstream.NewPostEventConsumer(a.postRepo, a.communityRepo, a.userService, a.db, + jetstream.WithPostBridgeTrust(a.bridgeTrust), + jetstream.WithPostIdentityResolver(a.identityResolver)), + }) + + // Aggregators: service declarations and authorization records, following + // Bluesky's feed generator and labeler pattern. + consumers = append(consumers, feedConsumer{ + name: jetstream.ConsumerAggregators, + handler: jetstream.NewAggregatorEventConsumer(a.aggregatorRepo, + jetstream.WithAggregatorRevGate(a.revGate)), + }) + + // Votes from user repositories, with atomic post/comment count updates. + consumers = append(consumers, feedConsumer{ + name: jetstream.ConsumerVotes, + handler: jetstream.NewVoteEventConsumer(a.voteRepo, a.userService, a.db), + }) + + // Comments from user repositories, with atomic parent count updates. + consumers = append(consumers, feedConsumer{ + name: jetstream.ConsumerComments, + handler: jetstream.NewCommentEventConsumer(a.commentRepo, a.db, + jetstream.WithCommentBridgeTrust(a.bridgeTrust)), + }) + + return consumers +} + +// warnIfNoPrimaryFeed flags a topology with no primary-key feed. This is +// expected in local dev (a self-only feed), but in production it usually means +// cursor continuity from the single-feed era is being forfeited. +func warnIfNoPrimaryFeed(feeds []jetstream.Feed) { + for _, feed := range feeds { + if feed.Key == jetstream.PrimaryFeedKey { + return + } + } + slog.Warn("no JETSTREAM_FEEDS entry uses the primary feed key: every consumer name will be "+ + "suffixed \"@\", so cursors persisted under the bare legacy names will NOT be used", + "primary_feed_key", jetstream.PrimaryFeedKey) +} diff --git a/cmd/server/database.go b/cmd/server/database.go new file mode 100644 index 0000000..b99edc3 --- /dev/null +++ b/cmd/server/database.go @@ -0,0 +1,109 @@ +package main + +import ( + "Coves/internal/config" + "Coves/internal/db/migrations" + "context" + "database/sql" + "fmt" + "log/slog" + + "github.com/pressly/goose/v3" +) + +// openDatabase runs schema migrations and returns the configured application +// connection pool. +// +// Migrations and application queries deliberately use separate connections. +// The application pool carries a statement_timeout so a runaway query cannot +// pin a connection indefinitely; migrations must not inherit that bound, +// because a CREATE INDEX or a backfill can legitimately run longer than any +// request handler should, and a migration cancelled halfway is far worse than +// a slow one. +// +// The caller owns the returned pool and must Close it. +func openDatabase(ctx context.Context, cfg config.DatabaseConfig) (*sql.DB, error) { + if err := runMigrations(ctx, cfg); err != nil { + return nil, err + } + + dsn, err := cfg.AppDSN() + if err != nil { + return nil, fmt.Errorf("building application DSN: %w", err) + } + + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, fmt.Errorf("opening AppView database: %w", err) + } + + // Bound the pool. database/sql defaults MaxOpenConns to unlimited, so a + // traffic spike can open connections until PostgreSQL's max_connections + // is exhausted — which locks out every other client, including psql and + // the maintenance commands under cmd/. + db.SetMaxOpenConns(cfg.MaxOpenConns) + db.SetMaxIdleConns(cfg.MaxIdleConns) + db.SetConnMaxLifetime(cfg.ConnMaxLifetime) + db.SetConnMaxIdleTime(cfg.ConnMaxIdleTime) + + if err := db.PingContext(ctx); err != nil { + // PingContext has already opened a connection and started the pool's + // background goroutines, so the pool must be closed even though it is + // unusable. + if closeErr := db.Close(); closeErr != nil { + slog.Error("failed to close database pool after failed ping", "error", closeErr) + } + return nil, fmt.Errorf("pinging AppView database: %w", err) + } + + slog.Info("connected to AppView database", + "max_open_conns", cfg.MaxOpenConns, + "max_idle_conns", cfg.MaxIdleConns, + "conn_max_lifetime", cfg.ConnMaxLifetime, + "conn_max_idle_time", cfg.ConnMaxIdleTime, + "statement_timeout", cfg.StatementTimeout, + ) + return db, nil +} + +// runMigrations applies pending migrations on a short-lived connection that +// carries no statement timeout. Migrations are embedded in the binary, so this +// does not depend on the process's working directory. +func runMigrations(ctx context.Context, cfg config.DatabaseConfig) error { + dsn, err := cfg.MigrationDSN() + if err != nil { + return fmt.Errorf("building migration DSN: %w", err) + } + + db, err := sql.Open("postgres", dsn) + if err != nil { + return fmt.Errorf("opening migration connection: %w", err) + } + defer func() { + if closeErr := db.Close(); closeErr != nil { + slog.Error("failed to close migration connection", "error", closeErr) + } + }() + + // Migrations are a single serial operation; one connection is enough and + // keeps this out of the way of the application pool's budget. + db.SetMaxOpenConns(1) + + if err := db.PingContext(ctx); err != nil { + return fmt.Errorf("pinging database for migrations: %w", err) + } + + if err := goose.SetDialect("postgres"); err != nil { + return fmt.Errorf("setting goose dialect: %w", err) + } + goose.SetBaseFS(migrations.FS) + + // "." is the root of the embedded filesystem, which contains only the + // migration files themselves. + if err := goose.UpContext(ctx, db, "."); err != nil { + return fmt.Errorf("running migrations: %w", err) + } + + slog.Info("database migrations applied") + return nil +} diff --git a/cmd/server/health.go b/cmd/server/health.go new file mode 100644 index 0000000..2a28e44 --- /dev/null +++ b/cmd/server/health.go @@ -0,0 +1,128 @@ +package main + +import ( + "Coves/internal/atproto/jetstream" + "encoding/json" + "log/slog" + "net/http" + "time" +) + +// consumerStalledThreshold is how long a consumer may be disconnected before +// /health/consumers reports "stalled" (503). +const consumerStalledThreshold = 60 * time.Second + +// consumerHealth is one consumer's entry in the /health/consumers response. +// +// LastEventAgeSeconds and CursorAgeSeconds are informational signals for +// operator alerting, NOT auto-503 inputs: a quiet local stream legitimately +// receives no events, so a large age alone cannot distinguish "nothing to +// index" from "connected but wedged". Operators who know their stream's +// expected cadence can alert on these externally. +type consumerHealth struct { + jetstream.ConnectorStatus + DeadLetterBacklog int64 `json:"deadLetterBacklog"` + LastEventAgeSeconds *int64 `json:"lastEventAgeSeconds,omitempty"` // omitted if no event received yet + CursorAgeSeconds *int64 `json:"cursorAgeSeconds,omitempty"` // omitted if the cursor is still 0 +} + +// consumerHealthResponse is the /health/consumers response body. +type consumerHealthResponse struct { + Status string `json:"status"` // "ok", "degraded", or "stalled" + // DeadLetterBacklogUnknown distinguishes "backlog is 0" from "the backlog + // could not be counted" (e.g. Postgres is down): without it the endpoint + // would look healthier the sicker the database gets. + DeadLetterBacklogUnknown bool `json:"deadLetterBacklogUnknown,omitempty"` + Consumers []consumerHealth `json:"consumers"` +} + +// buildConsumerHealthResponse is the pure decision core of /health/consumers, +// extracted so tests can drive it with hand-built statuses. Rules: +// - any consumer disconnected longer than consumerStalledThreshold → +// "stalled" + 503. +// - dead letter backlog uncountable → "degraded" + 200 (stalled wins). +// - otherwise "ok" + 200. +// +// A connector reporting no DisconnectedSince is not stalled, but note that in +// production this only describes a connector that was never *started*: +// Connector.Start sets disconnected-since-boot as its first action precisely +// so that a consumer which never achieves its first connection still surfaces +// as stalled after the threshold. The never-started case exists only in tests. +func buildConsumerHealthResponse(statuses []jetstream.ConnectorStatus, backlogs map[string]int64, backlogUnknown bool, now time.Time) (consumerHealthResponse, int) { + // Pre-allocated rather than left nil so the JSON field is always [] and + // never null, which keeps the response shape stable for typed clients. + response := consumerHealthResponse{ + Status: "ok", + Consumers: make([]consumerHealth, 0, len(statuses)), + } + httpCode := http.StatusOK + if backlogUnknown { + response.Status = "degraded" + response.DeadLetterBacklogUnknown = true + } + + for _, status := range statuses { + if !status.Connected && status.DisconnectedSince != nil && + now.Sub(*status.DisconnectedSince) > consumerStalledThreshold { + response.Status = "stalled" + httpCode = http.StatusServiceUnavailable + } + + entry := consumerHealth{ + ConnectorStatus: status, + DeadLetterBacklog: backlogs[status.Name], + } + if status.LastEventAt != nil { + age := int64(now.Sub(*status.LastEventAt).Seconds()) + entry.LastEventAgeSeconds = &age + } + if status.CursorTimeUS != 0 { + age := int64(now.Sub(time.UnixMicro(status.CursorTimeUS)).Seconds()) + entry.CursorAgeSeconds = &age + } + response.Consumers = append(response.Consumers, entry) + } + return response, httpCode +} + +// consumerHealthHandler reports Jetstream consumer health as JSON: connection +// state, cursor position, processed/dead-lettered counts, event/cursor ages, +// and the dead letter backlog per consumer. Responds 503 when any consumer +// has been disconnected longer than consumerStalledThreshold (indexing is +// stalled) so monitoring can alert on it. +func consumerHealthHandler(connectors []*jetstream.Connector, deadLetterQueue jetstream.DeadLetterQueue) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + backlogs, err := deadLetterQueue.CountDeadLetters(r.Context()) + backlogUnknown := err != nil + if backlogUnknown { + // Log the error server-side only: this is a public endpoint, so + // the response carries just the deadLetterBacklogUnknown flag. + slog.Error("failed to count dead letters for health check", "error", err) + } + + statuses := make([]jetstream.ConnectorStatus, 0, len(connectors)) + for _, connector := range connectors { + statuses = append(statuses, connector.Status()) + } + + response, httpCode := buildConsumerHealthResponse(statuses, backlogs, backlogUnknown, time.Now()) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(httpCode) + if err := json.NewEncoder(w).Encode(response); err != nil { + slog.Error("failed to write consumer health response", "error", err) + } + } +} + +// livenessHandler answers /health and /xrpc/_health. +// +// These stay pure liveness checks — they are the container healthcheck target, +// and a Jetstream outage must not restart-loop the whole AppView. Indexing +// health is reported separately by /health/consumers. +func livenessHandler(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("OK")); err != nil { + slog.Error("failed to write health check response", "error", err) + } +} diff --git a/cmd/server/main_test.go b/cmd/server/health_test.go similarity index 99% rename from cmd/server/main_test.go rename to cmd/server/health_test.go index dabcc99..b2f191f 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/health_test.go @@ -1,6 +1,7 @@ package main import ( + "Coves/internal/atproto/jetstream" "context" "encoding/json" "errors" @@ -9,8 +10,6 @@ import ( "strings" "testing" "time" - - "Coves/internal/atproto/jetstream" ) // noopEventHandler satisfies jetstream.EventHandler for connectors that are diff --git a/cmd/server/httpserver.go b/cmd/server/httpserver.go new file mode 100644 index 0000000..d2d90a4 --- /dev/null +++ b/cmd/server/httpserver.go @@ -0,0 +1,29 @@ +package main + +import ( + "Coves/internal/config" + "net/http" +) + +// newHTTPServer builds the HTTP server with every timeout set. +// +// net/http defaults all four to zero, which means "no deadline". A client that +// opens a connection and then sends its request headers one byte at a time +// holds a goroutine and a file descriptor for as long as it likes; enough of +// them exhaust the process's file-descriptor limit without a single complete +// request being sent. That is the slowloris attack, and ReadHeaderTimeout is +// the specific defence against it. +// +// The remaining three bound the other ways a connection can be held open: a +// slow request body (ReadTimeout), a slow response consumer (WriteTimeout), +// and an idle keep-alive connection that is never reused (IdleTimeout). +func newHTTPServer(cfg config.ServerConfig, handler http.Handler) *http.Server { + return &http.Server{ + Addr: ":" + cfg.Port, + Handler: handler, + ReadHeaderTimeout: cfg.ReadHeaderTimeout, + ReadTimeout: cfg.ReadTimeout, + WriteTimeout: cfg.WriteTimeout, + IdleTimeout: cfg.IdleTimeout, + } +} diff --git a/cmd/server/httpserver_test.go b/cmd/server/httpserver_test.go new file mode 100644 index 0000000..0eeed58 --- /dev/null +++ b/cmd/server/httpserver_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "Coves/internal/config" + "Coves/internal/core/imageproxy" + "net/http" + "testing" + "time" +) + +// A zero timeout in net/http means "no deadline", so every one of these must +// be carried through from config. This test is the regression guard: dropping +// any of them reintroduces the slowloris exposure silently, because the server +// still works perfectly for well-behaved clients. +func TestNewHTTPServer_AppliesEveryTimeout(t *testing.T) { + cfg := config.ServerConfig{ + Port: "9090", + ReadHeaderTimeout: 11 * time.Second, + ReadTimeout: 22 * time.Second, + WriteTimeout: 33 * time.Second, + IdleTimeout: 44 * time.Second, + ShutdownTimeout: 55 * time.Second, + } + + server := newHTTPServer(cfg, http.NotFoundHandler()) + + if server.Addr != ":9090" { + t.Errorf("Addr = %q, want %q", server.Addr, ":9090") + } + + timeouts := []struct { + name string + got time.Duration + want time.Duration + }{ + {"ReadHeaderTimeout", server.ReadHeaderTimeout, cfg.ReadHeaderTimeout}, + {"ReadTimeout", server.ReadTimeout, cfg.ReadTimeout}, + {"WriteTimeout", server.WriteTimeout, cfg.WriteTimeout}, + {"IdleTimeout", server.IdleTimeout, cfg.IdleTimeout}, + } + for _, tc := range timeouts { + if tc.got != tc.want { + t.Errorf("%s = %v, want %v", tc.name, tc.got, tc.want) + } + if tc.got == 0 { + t.Errorf("%s is zero, which net/http reads as no deadline at all", tc.name) + } + } + + if server.Handler == nil { + t.Error("Handler must be set") + } +} + +// ShutdownTimeout belongs to the shutdown path, not the listener, so it must +// not leak into any of the server's own deadlines. +func TestNewHTTPServer_ShutdownTimeoutIsNotAListenerTimeout(t *testing.T) { + cfg := config.ServerConfig{ + Port: "8080", + ReadHeaderTimeout: time.Second, + ReadTimeout: 2 * time.Second, + WriteTimeout: 3 * time.Second, + IdleTimeout: 4 * time.Second, + ShutdownTimeout: 99 * time.Second, + } + + server := newHTTPServer(cfg, http.NotFoundHandler()) + + for name, got := range map[string]time.Duration{ + "ReadHeaderTimeout": server.ReadHeaderTimeout, + "ReadTimeout": server.ReadTimeout, + "WriteTimeout": server.WriteTimeout, + "IdleTimeout": server.IdleTimeout, + } { + if got == cfg.ShutdownTimeout { + t.Errorf("%s picked up ShutdownTimeout (%v)", name, got) + } + } +} + +// The defaults must actually be usable: the image proxy can spend its full +// fetch timeout on a remote PDS before writing a byte, so a WriteTimeout at or +// below that would truncate legitimate image responses. +// +// The relation is asserted against imageproxy's own default rather than a +// hardcoded 30s, so raising that default fails here instead of silently +// invalidating the invariant. +func TestNewHTTPServer_DefaultWriteTimeoutAccommodatesImageProxy(t *testing.T) { + // config.Load reads the whole environment, so the test must be hermetic: + // a stray legacy JETSTREAM_URL or malformed DB_* in the developer's shell + // would fail this for a reason unrelated to what it asserts. + config.ClearEnvForTest(t) + t.Setenv("IS_DEV_ENV", "true") + + cfg, err := config.Load() + if err != nil { + t.Fatalf("config.Load() returned error: %v", err) + } + + server := newHTTPServer(cfg.Server, http.NotFoundHandler()) + imageProxyFetchTimeout := imageproxy.DefaultConfig().FetchTimeout + if server.WriteTimeout <= imageProxyFetchTimeout { + t.Errorf("default WriteTimeout = %v, must exceed the image proxy's %v fetch timeout", + server.WriteTimeout, imageProxyFetchTimeout) + } +} diff --git a/cmd/server/jobs.go b/cmd/server/jobs.go new file mode 100644 index 0000000..e79ef50 --- /dev/null +++ b/cmd/server/jobs.go @@ -0,0 +1,173 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "sync" + "time" +) + +const ( + // oauthCleanupInterval is how often expired OAuth sessions and pending + // authorization requests are purged. + oauthCleanupInterval = time.Hour + + // tokenRefreshInterval is how often aggregator OAuth tokens are checked + // for imminent expiry. + tokenRefreshInterval = 30 * time.Minute + + // tokenRefreshExpiryBuffer is how far ahead of expiry a token is + // refreshed. At two ticks of headroom, a single failed attempt still + // leaves another before the token actually expires. + tokenRefreshExpiryBuffer = time.Hour + + // tokenRefreshHeartbeatCycles controls how often the refresh job logs + // that it is alive while it has no work to do. At 6 cycles that is once + // every three hours — enough to distinguish "idle" from "dead". + tokenRefreshHeartbeatCycles = 6 +) + +// runTicker runs work on an interval until ctx is cancelled, recovering from +// panics so a single bad cycle cannot kill the job permanently. +// +// That recovery placement is the point. A recover() guarding the whole +// goroutine logs the panic and then lets the job exit forever, which for +// aggregator token refresh means every aggregator silently stops working at +// the next token expiry — a failure with no error, no alert, and no restart. +// Recovering per cycle turns that into one lost tick. +// +// A cycle runs immediately at startup rather than only after the first full +// interval, matching the dead letter redriver: work that accumulated while the +// process was down should not have to wait out a 30- or 60-minute tick, and a +// frequently-restarted deployment would otherwise never run these jobs at all. +func runTicker(ctx context.Context, wg *sync.WaitGroup, name string, interval time.Duration, work func(context.Context)) { + wg.Add(1) + go func() { + defer wg.Done() + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + runGuarded(ctx, name, interval, work) + + for { + select { + case <-ctx.Done(): + slog.Info("background job stopped", "job", name) + return + case <-ticker.C: + runGuarded(ctx, name, interval, work) + } + } + }() +} + +// runGuarded executes one cycle, converting a panic into a logged error so the +// surrounding loop survives, and bounding the cycle so a hang cannot stop the +// job permanently. +// +// The deadline is as important as the recover. time.Ticker drops ticks while +// the receiver is busy, so a cycle that blocks forever means the loop never +// re-enters its select: the job stops running, never observes cancellation, +// and logs nothing. That is strictly worse than the panic case, which at least +// leaves a record. +func runGuarded(ctx context.Context, name string, timeout time.Duration, work func(context.Context)) { + defer func() { + if recovered := recover(); recovered != nil { + slog.Error("background job cycle panicked; job continues", + "job", name, + "panic", recovered, + ) + } + }() + + cycleCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + work(cycleCtx) +} + +// expiredRecordCleaner removes OAuth records that have aged out. Declared here +// rather than taking the concrete store so the job body is testable without a +// database. +type expiredRecordCleaner interface { + CleanupExpiredSessions(ctx context.Context) (int64, error) + CleanupExpiredAuthRequests(ctx context.Context) (int64, error) +} + +// startOAuthCleanupJob purges expired OAuth sessions and abandoned +// authorization requests on an interval, so neither table grows without bound. +// +// The cleaner is resolved once, by the caller, rather than re-derived inside +// every cycle: the previous shape returned early on a nil store with no log, +// so wrapping the OAuth store in any decorator would have turned this into a +// silent hourly no-op while both tables grew without bound — discovered +// eventually as disk pressure, with nothing pointing back here. +func startOAuthCleanupJob(ctx context.Context, wg *sync.WaitGroup, cleaner expiredRecordCleaner) { + runTicker(ctx, wg, "oauth-cleanup", oauthCleanupInterval, func(ctx context.Context) { + sessions, err := cleaner.CleanupExpiredSessions(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + slog.Error("failed to clean up expired OAuth sessions", "error", err) + } + requests, err := cleaner.CleanupExpiredAuthRequests(ctx) + if err != nil && !errors.Is(err, context.Canceled) { + slog.Error("failed to clean up expired OAuth auth requests", "error", err) + } + if sessions > 0 || requests > 0 { + slog.Info("OAuth cleanup completed", + "expired_sessions_removed", sessions, + "expired_auth_requests_removed", requests, + ) + } + }) +} + +// expiringTokenRefresher renews aggregator OAuth tokens that are close to +// expiry. An interface rather than the concrete service so the job body can be +// exercised without a database. +type expiringTokenRefresher interface { + RefreshExpiringTokens(ctx context.Context, expiryBuffer time.Duration) (int, []error) +} + +// startAggregatorTokenRefreshJob proactively refreshes aggregator OAuth tokens +// before they expire. +// +// This complements the on-demand refresh inside APIKeyService (which uses a +// much shorter buffer): without it, an aggregator that goes idle long enough +// for its token to expire would find its next request rejected rather than +// transparently refreshed. +func startAggregatorTokenRefreshJob(ctx context.Context, wg *sync.WaitGroup, refresher expiringTokenRefresher) { + cycleCount := 0 + runTicker(ctx, wg, "aggregator-token-refresh", tokenRefreshInterval, func(ctx context.Context) { + cycleCount++ + + refreshed, errs := refresher.RefreshExpiringTokens(ctx, tokenRefreshExpiryBuffer) + + // Cancellation during shutdown is not a failure. Logging it at ERROR + // meant every clean deploy produced error-rate noise. + reportable := errs[:0:0] + for _, err := range errs { + if !errors.Is(err, context.Canceled) { + reportable = append(reportable, err) + } + } + + switch { + case len(reportable) > 0: + slog.Warn("aggregator token refresh completed with errors", + "refreshed", refreshed, + "failed", len(reportable), + ) + for _, err := range reportable { + slog.Error("aggregator token refresh error", "error", err) + } + case refreshed > 0: + slog.Info("aggregator token refresh completed", "refreshed", refreshed) + case cycleCount%tokenRefreshHeartbeatCycles == 0: + slog.Info("aggregator token refresh heartbeat: running, no tokens needed refresh", + "cycles_completed", cycleCount, + ) + } + }) +} diff --git a/cmd/server/jobs_test.go b/cmd/server/jobs_test.go new file mode 100644 index 0000000..6dce76d --- /dev/null +++ b/cmd/server/jobs_test.go @@ -0,0 +1,231 @@ +package main + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +// waitFor polls until cond holds or the deadline passes. Ticker-driven jobs +// are inherently timing-dependent, so tests poll rather than sleep a fixed +// amount and hope. +func waitFor(t *testing.T, timeout time.Duration, cond func() bool) bool { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return true + } + time.Sleep(time.Millisecond) + } + return cond() +} + +// The reason recovery lives per cycle rather than around the whole goroutine: +// a job that dies on its first panic takes aggregator token refresh with it, +// and every aggregator silently stops working at the next token expiry — no +// error, no alert, no restart. +func TestRunTicker_SurvivesAPanickingCycle(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var wg sync.WaitGroup + var cycles atomic.Int64 + + runTicker(ctx, &wg, "panicky", time.Millisecond, func(context.Context) { + if cycles.Add(1) == 1 { + panic("first cycle explodes") + } + }) + + if !waitFor(t, 5*time.Second, func() bool { return cycles.Load() >= 3 }) { + t.Fatalf("job ran %d cycles after a panic; it should have kept going", cycles.Load()) + } + + cancel() + wg.Wait() +} + +func TestRunTicker_StopsOnContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + var wg sync.WaitGroup + var cycles atomic.Int64 + + runTicker(ctx, &wg, "counter", time.Millisecond, func(context.Context) { + cycles.Add(1) + }) + + if !waitFor(t, 5*time.Second, func() bool { return cycles.Load() >= 2 }) { + t.Fatal("job never ran") + } + + cancel() + + // wg.Wait must return: shutdown blocks on it, so a job that ignored + // cancellation would hang the drain until the shutdown timeout expired. + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("job did not exit after context cancellation") + } + + // No further cycles once the WaitGroup has been released. + settled := cycles.Load() + time.Sleep(20 * time.Millisecond) + if got := cycles.Load(); got != settled { + t.Errorf("job ran %d more cycles after exiting", got-settled) + } +} + +// Work receives a live, deadline-bounded context derived from the job's own, +// so a cycle is cut short by shutdown rather than blocking the drain. +func TestRunTicker_PassesLiveBoundedContextToWork(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var wg sync.WaitGroup + type observation struct { + err error + deadline time.Time + hasDeadline bool + } + observed := make(chan observation, 1) + + runTicker(ctx, &wg, "ctx-check", time.Minute, func(workCtx context.Context) { + deadline, ok := workCtx.Deadline() + select { + case observed <- observation{err: workCtx.Err(), deadline: deadline, hasDeadline: ok}: + default: + } + }) + + var got observation + select { + case got = <-observed: + case <-time.After(5 * time.Second): + t.Fatal("job never ran its first cycle") + } + + if got.err != nil { + t.Errorf("work received an already-cancelled context: %v", got.err) + } + // The deadline is what stops a hung cycle from silently killing the job: + // without it, a blocked cycle means the ticker loop never re-enters its + // select and the job stops forever with nothing logged. + if !got.hasDeadline { + t.Error("work's context has no deadline; a hung cycle would stall the job permanently") + } + + cancel() + wg.Wait() +} + +// Cancelling the job context must interrupt a cycle that is already running, +// not merely stop the next one from starting. +func TestRunTicker_CancelInterruptsAnInFlightCycle(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var wg sync.WaitGroup + started := make(chan struct{}) + finished := make(chan error, 1) + + runTicker(ctx, &wg, "long-cycle", time.Minute, func(workCtx context.Context) { + select { + case started <- struct{}{}: + default: + return + } + <-workCtx.Done() + finished <- workCtx.Err() + }) + + select { + case <-started: + case <-time.After(5 * time.Second): + t.Fatal("job never ran its first cycle") + } + + cancel() + + select { + case err := <-finished: + if err == nil { + t.Error("in-flight cycle's context was not cancelled") + } + case <-time.After(5 * time.Second): + t.Fatal("cancelling the job did not interrupt the running cycle") + } + + wg.Wait() +} + +// Every job must do a pass at boot. Waiting out a full 30- or 60-minute +// interval means a frequently-restarted deployment never refreshes aggregator +// tokens at all, and a backlog accumulated while the process was down sits +// untouched. +func TestRunTicker_RunsImmediatelyAtStartup(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var wg sync.WaitGroup + ran := make(chan struct{}, 1) + + // An interval far longer than the test's patience: only a startup cycle + // can satisfy this. + runTicker(ctx, &wg, "startup-cycle", time.Hour, func(context.Context) { + select { + case ran <- struct{}{}: + default: + } + }) + + select { + case <-ran: + case <-time.After(5 * time.Second): + t.Fatal("job did not run a cycle at startup; it waited for the first tick") + } + + cancel() + wg.Wait() +} + +func TestRunGuarded_ConvertsPanicToReturn(t *testing.T) { + // The bare call must not propagate the panic to the caller. + runGuarded(context.Background(), "boom", time.Second, func(context.Context) { + panic("kaboom") + }) + + ran := false + runGuarded(context.Background(), "fine", time.Second, func(context.Context) { + ran = true + }) + if !ran { + t.Error("runGuarded did not execute the work function") + } +} + +// Runtime panics must be contained too, not just explicit panic() calls: a +// nil dereference or an out-of-range index inside a job is exactly the kind of +// bug this guard exists to survive. +func TestRunGuarded_ContainsRuntimePanics(t *testing.T) { + runGuarded(context.Background(), "index-out-of-range", time.Second, func(context.Context) { + values := []int{1, 2, 3} + index := len(values) + 1 + _ = values[index] + }) + + runGuarded(context.Background(), "nil-deref", time.Second, func(context.Context) { + type box struct{ n int } + var b *box + _ = b.n + }) +} diff --git a/cmd/server/main.go b/cmd/server/main.go index 68abab5..a724579 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,1302 +1,288 @@ +// Command server runs the Coves AppView: the HTTP/XRPC API, the Jetstream +// consumers that index the atProto firehose into PostgreSQL, and the +// background jobs that keep OAuth sessions and aggregator tokens fresh. package main import ( - "bytes" + "Coves/internal/atproto/oauth" + "Coves/internal/config" + "Coves/internal/core/users" + "Coves/internal/observability" "context" - "crypto/rand" - "database/sql" - "encoding/base64" - "encoding/json" "errors" "fmt" - "io" - "log" "log/slog" "net/http" "os" "os/signal" - "strings" "sync" "syscall" "time" - "Coves/internal/api/middleware" - "Coves/internal/api/routes" - "Coves/internal/atproto/identity" - "Coves/internal/atproto/jetstream" - "Coves/internal/atproto/oauth" - "Coves/internal/observability" - - imageproxyhandlers "Coves/internal/api/handlers/imageproxy" - "Coves/internal/core/imageproxy" - - "Coves/internal/core/adminreports" - "Coves/internal/core/aggregators" - "Coves/internal/core/blobs" - "Coves/internal/core/blueskypost" - "Coves/internal/core/comments" - "Coves/internal/core/communities" - "Coves/internal/core/communityFeeds" - "Coves/internal/core/communitysuggestions" - "Coves/internal/core/discover" - "Coves/internal/core/posts" - "Coves/internal/core/timeline" - "Coves/internal/core/unfurl" - "Coves/internal/core/userblocks" - "Coves/internal/core/users" - "Coves/internal/core/votes" - indigoauth "github.com/bluesky-social/indigo/atproto/auth" - indigoidentity "github.com/bluesky-social/indigo/atproto/identity" - - "github.com/go-chi/chi/v5" - chiMiddleware "github.com/go-chi/chi/v5/middleware" _ "github.com/lib/pq" - "github.com/pressly/goose/v3" - - commentsAPI "Coves/internal/api/handlers/comments" - - postgresRepo "Coves/internal/db/postgres" ) // Compile-time interface satisfaction checks var _ oauth.UserIndexer = (users.UserService)(nil) func main() { - // Database configuration (AppView database) - dbURL := os.Getenv("DATABASE_URL") - if dbURL == "" { - // Use dev database from .env.dev - dbURL = "postgres://dev_user:dev_password@localhost:5435/coves_dev?sslmode=disable" - } - - // Default PDS URL for this Coves instance (supports self-hosting) - defaultPDS := os.Getenv("PDS_URL") - if defaultPDS == "" { - defaultPDS = "http://localhost:3001" // Local dev PDS + if err := run(); err != nil { + slog.Error("server failed to start", "error", err) + os.Exit(1) } +} - // Bot-protected signup configuration. - // TURNSTILE_SITE_KEY: PUBLIC Cloudflare key embedded in the /m/turnstile.html page - // the mobile WebView loads. Empty → that page returns 503. - // TURNSTILE_SECRET_KEY: Cloudflare Turnstile server secret for verifying tokens. - // PDS_ADMIN_PASSWORD: used to mint single-use PDS invite codes on captcha success. - // All three are optional — if any is missing, the corresponding endpoint returns 503. - // Signup remains gated by PDS_INVITE_REQUIRED, so missing config means signup is - // *closed*, not bypassed. - turnstileSiteKey := os.Getenv("TURNSTILE_SITE_KEY") - turnstileSecret := os.Getenv("TURNSTILE_SECRET_KEY") - pdsAdminPassword := os.Getenv("PDS_ADMIN_PASSWORD") - signupTokenEnabled := turnstileSecret != "" && pdsAdminPassword != "" - if !signupTokenEnabled { - // Structured Warn (not log.Println) so log aggregators can alert on - // level + attrs — log.Println at startup gets stuck at INFO and is hard - // to filter on. - slog.Warn("signup-token endpoint DISABLED: new signups blocked", - slog.Bool("turnstile_secret_set", turnstileSecret != ""), - slog.Bool("pds_admin_password_set", pdsAdminPassword != ""), - ) - } - if turnstileSiteKey == "" { - slog.Warn("/m/turnstile.html DISABLED: TURNSTILE_SITE_KEY not set; mobile signup will fail at captcha", - slog.Bool("turnstile_site_key_set", false), - ) - } - var turnstileVerifier users.TurnstileVerifier - if turnstileSecret != "" { - turnstileVerifier = users.NewCloudflareTurnstile(turnstileSecret) +// run performs startup, serves until a shutdown signal arrives, and then +// drains cleanly. +// +// Returning an error rather than calling log.Fatal is what makes the deferred +// cleanup below reachable: os.Exit skips deferred functions, so a fatal call +// partway through startup would abandon the database pool and discard buffered +// OpenTelemetry spans — including the ones describing the failure. +func run() error { + cfg, err := config.Load() + if err != nil { + return err } + logStartupWarnings(cfg) - // Cursor secret for HMAC signing (prevents cursor manipulation) - cursorSecret := os.Getenv("CURSOR_SECRET") - if cursorSecret == "" { - // Generate a random secret if not set (dev mode) - // IMPORTANT: In production, set CURSOR_SECRET to a strong random value - cursorSecret = "dev-cursor-secret-change-in-production" - log.Println("⚠️ WARNING: Using default cursor secret. Set CURSOR_SECRET env var in production!") - } + // Signal handling is installed first so a Ctrl-C during the slower parts + // of startup (PDS login, migrations) still shuts down in an orderly way. + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() - db, err := sql.Open("postgres", dbURL) + db, err := openDatabase(ctx, cfg.Database) if err != nil { - log.Fatal("Failed to connect to database:", err) + return err } defer func() { if closeErr := db.Close(); closeErr != nil { - log.Printf("Failed to close database connection: %v", closeErr) + slog.Error("failed to close database pool", "error", closeErr) } }() - if err = db.Ping(); err != nil { - log.Fatal("Failed to ping database:", err) - } - - log.Println("Connected to AppView database") - - // Run migrations - if err = goose.SetDialect("postgres"); err != nil { - log.Fatal("Failed to set goose dialect:", err) - } - - if err = goose.Up(db, "internal/db/migrations"); err != nil { - log.Fatal("Failed to run migrations:", err) - } - - log.Println("Migrations completed successfully") - - // Initialize optional OpenTelemetry observability - otelConfig := observability.ConfigFromEnv() - if err := otelConfig.Validate(); err != nil { - log.Fatalf("Invalid OpenTelemetry configuration: %v", err) - } - otelProvider, err := observability.NewProvider(context.Background(), otelConfig) + otelProvider, err := startObservability(ctx) if err != nil { - log.Fatalf("Failed to initialize OpenTelemetry: %v", err) + return err } defer func() { + // A fresh context: ctx may already be cancelled by the time this + // runs, and a cancelled context would discard the spans instead of + // flushing them. if shutdownErr := otelProvider.Shutdown(context.Background()); shutdownErr != nil { - log.Printf("Error shutting down OpenTelemetry: %v", shutdownErr) + slog.Error("failed to shut down OpenTelemetry", "error", shutdownErr) } }() - if otelConfig.Enabled { - log.Printf("OpenTelemetry tracing enabled (endpoint: %s)", otelConfig.Endpoint) - } - - r := chi.NewRouter() - - r.Use(chiMiddleware.Logger) - r.Use(chiMiddleware.Recoverer) - r.Use(chiMiddleware.RequestID) - - // Rate limiting: 100 requests per minute per IP - rateLimiter := middleware.NewRateLimiter(100, 1*time.Minute) - r.Use(rateLimiter.Middleware) - - // Optional: OpenTelemetry HTTP tracing middleware - if otelMiddleware := observability.HTTPMiddleware(otelProvider); otelMiddleware != nil { - r.Use(otelMiddleware) - } - - // Initialize identity resolver - // IMPORTANT: In dev mode, identity resolution MUST use the same local PLC - // directory as DID registration to ensure E2E tests work without hitting - // the production plc.directory - identityConfig := identity.DefaultConfig() - - isDevEnv := os.Getenv("IS_DEV_ENV") == "true" - plcDirectoryURL := os.Getenv("PLC_DIRECTORY_URL") - if plcDirectoryURL == "" { - plcDirectoryURL = "https://plc.directory" // Default to production PLC - } - - // In dev mode, use PLC_DIRECTORY_URL for identity resolution - // In prod mode, use IDENTITY_PLC_URL if set, otherwise PLC_DIRECTORY_URL - if isDevEnv { - identityConfig.PLCURL = plcDirectoryURL - log.Printf("🧪 DEV MODE: Identity resolver will use local PLC: %s", plcDirectoryURL) - } else { - // Production: Allow separate IDENTITY_PLC_URL for read operations - if identityPLCURL := os.Getenv("IDENTITY_PLC_URL"); identityPLCURL != "" { - identityConfig.PLCURL = identityPLCURL - } else { - identityConfig.PLCURL = plcDirectoryURL - } - log.Printf("✅ PRODUCTION MODE: Identity resolver using PLC: %s", identityConfig.PLCURL) - } - - if cacheTTL := os.Getenv("IDENTITY_CACHE_TTL"); cacheTTL != "" { - if duration, parseErr := time.ParseDuration(cacheTTL); parseErr == nil { - identityConfig.CacheTTL = duration - } - } - - identityResolver := identity.NewResolver(db, identityConfig) - - // Get PLC URL for OAuth and other services - plcURL := os.Getenv("PLC_DIRECTORY_URL") - if plcURL == "" { - plcURL = "https://plc.directory" - } - log.Printf("🔐 OAuth will use PLC directory: %s", plcURL) - - // Initialize OAuth client for sealed session tokens - // Mobile apps authenticate via OAuth flow and receive sealed session tokens - // These tokens are encrypted references to OAuth sessions stored in the database - oauthSealSecret := os.Getenv("OAUTH_SEAL_SECRET") - if oauthSealSecret == "" { - if os.Getenv("IS_DEV_ENV") != "true" { - log.Fatal("OAUTH_SEAL_SECRET is required in production mode") - } - // Generate RANDOM secret for dev mode - randomBytes := make([]byte, 32) - if _, err := rand.Read(randomBytes); err != nil { - log.Fatal("Failed to generate random seal secret: ", err) - } - oauthSealSecret = base64.StdEncoding.EncodeToString(randomBytes) - log.Println("⚠️ DEV MODE: Generated random OAuth seal secret (won't persist across restarts)") - } - - isDevMode := os.Getenv("IS_DEV_ENV") == "true" - pdsURL := os.Getenv("PDS_URL") // For dev mode: resolve handles via local PDS - - oauthConfig := &oauth.OAuthConfig{ - PublicURL: os.Getenv("APPVIEW_PUBLIC_URL"), - SealSecret: oauthSealSecret, - Scopes: []string{ - "atproto", - "blob:*/*", // For avatar/image uploads - // Posts - "repo:social.coves.community.post?action=create&action=update&action=delete", - // Comments - "repo:social.coves.community.comment?action=create&action=update&action=delete", - // Communities - "repo:social.coves.community.profile?action=create&action=update&action=delete", - // Subscriptions - "repo:social.coves.community.subscription?action=create&action=update&action=delete", - // User profile - "repo:social.coves.actor.profile?action=create&action=update&action=delete", - // Votes - "repo:social.coves.feed.vote?action=create&action=delete", - // User blocks - "repo:social.coves.actor.block?action=create&action=delete", - }, - DevMode: isDevMode, - AllowPrivateIPs: isDevMode, // Allow private IPs only in dev mode - PLCURL: plcURL, - PDSURL: pdsURL, // For dev mode handle resolution - // Confidential client keys (optional - if set, upgrades to confidential client) - // Confidential clients: 90-day session TTL, 180-day sealed token TTL - // Public clients: Limited to 14 days by auth server regardless of config - ClientPrivateKeyMultibase: os.Getenv("OAUTH_CLIENT_PRIVATE_KEY"), - ClientKeyID: os.Getenv("OAUTH_CLIENT_KEY_ID"), - // SessionTTL and SealedTokenTTL use defaults if not set (90 days and 180 days) - } - - // Create PostgreSQL-backed OAuth session store (using default 7-day TTL) - baseOAuthStore := oauth.NewPostgresOAuthStore(db, 0) - // Wrap with MobileAwareStoreWrapper to capture OAuth state for mobile CSRF validation. - // This intercepts SaveAuthRequestInfo to save mobile CSRF data when present in context. - oauthStore := oauth.NewMobileAwareStoreWrapper(baseOAuthStore) - - if oauthConfig.PublicURL == "" { - oauthConfig.PublicURL = "http://localhost:8080" - oauthConfig.DevMode = true // Force dev mode for localhost - } - oauthClient, err := oauth.NewOAuthClient(oauthConfig, oauthStore) + app, err := buildApplication(ctx, cfg, db) if err != nil { - log.Fatalf("Failed to initialize OAuth client: %v", err) + return err } + defer app.Close() - // Initialize user repository and service early (needed for OAuth user indexing) - // Profile backfill: users indexed with no profile data (e.g. their profile - // firehose event was missed) get social.coves.actor.profile fetched from - // their PDS asynchronously, best-effort, during IndexUser. - userRepo := postgresRepo.NewUserRepository(db) - userService := users.NewUserService(userRepo, identityResolver, defaultPDS, turnstileVerifier, pdsAdminPassword, - users.WithProfileBackfill(&http.Client{Timeout: 10 * time.Second})) + // Background work runs on its own context so shutdown can stop producing + // new work before it starts draining in-flight requests. + backgroundCtx, stopBackground := context.WithCancel(context.Background()) + defer stopBackground() + var backgroundWG sync.WaitGroup - // Create OAuth handler for HTTP endpoints - // WithUserIndexer ensures users are indexed into local database after OAuth login - oauthHandler := oauth.NewOAuthHandler(oauthClient, oauthStore, oauth.WithUserIndexer(userService)) - - // Create OAuth auth middleware - // Validates sealed session tokens and loads OAuth sessions from database - authMiddleware := middleware.NewOAuthAuthMiddleware(oauthClient, oauthStore) - log.Println("✅ OAuth auth middleware initialized (sealed session tokens)") - - // Create identity directory for service auth validator - // This is used to verify DIDs in service JWTs for aggregator authentication - // Note: The 10-second timeout here is for HTTP requests made by the identity resolver itself, - // not for the auth middleware's request context. The middleware passes r.Context() to the validator, - // which properly respects request cancellation. This timeout is a safety net for slow DID resolution. - identityDir := &indigoidentity.BaseDirectory{ - PLCURL: plcURL, - HTTPClient: http.Client{Timeout: 10 * time.Second}, - } - - communityRepo := postgresRepo.NewCommunityRepository(db) - - // V2.0: PDS-managed DID generation - // Community DIDs and keys are generated entirely by the PDS - // No Coves-side DID generator needed (reserved for future V2.1 hybrid approach) - - instanceDID := os.Getenv("INSTANCE_DID") - if instanceDID == "" { - instanceDID = "did:web:coves.social" // Default for development - } - - // V2: Extract instance domain for community handles - // IMPORTANT: This MUST match the domain in INSTANCE_DID for security - // We cannot allow arbitrary domains to prevent impersonation attacks - // Example attack: !leagueoflegends@riotgames.com on a non-Riot instance - // - // SECURITY: did:web domain verification is implemented in the Jetstream consumer - // See: internal/atproto/jetstream/community_consumer.go - verifyHostedByClaim() - // Communities with mismatched hostedBy domains are rejected during indexing - var instanceDomain string - if strings.HasPrefix(instanceDID, "did:web:") { - // Extract domain from did:web (this is the authoritative source) - instanceDomain = strings.TrimPrefix(instanceDID, "did:web:") - } else { - // For non-web DIDs (e.g., did:plc), require explicit INSTANCE_DOMAIN - instanceDomain = os.Getenv("INSTANCE_DOMAIN") - if instanceDomain == "" { - log.Fatal("INSTANCE_DOMAIN must be set for non-web DIDs") - } - } - - log.Printf("Instance domain: %s (extracted from DID: %s)", instanceDomain, instanceDID) - - // Community creation restriction - if set, only these DIDs can create communities - var allowedCommunityCreators []string - if communityCreators := os.Getenv("COMMUNITY_CREATORS"); communityCreators != "" { - for _, did := range strings.Split(communityCreators, ",") { - did = strings.TrimSpace(did) - if did != "" { - allowedCommunityCreators = append(allowedCommunityCreators, did) - } - } - log.Printf("Community creation restricted to %d DIDs", len(allowedCommunityCreators)) - } else { - log.Println("Community creation open to all authenticated users") + // Resolved here, once, so a store that cannot be unwrapped stops the boot + // instead of turning the cleanup job into a silent hourly no-op. + sessionStore := app.oauthStore.UnwrapPostgresStore() + if sessionStore == nil { + return errors.New("OAuth store does not expose a PostgreSQL store: " + + "expired sessions and auth requests could never be cleaned up") } + startOAuthCleanupJob(backgroundCtx, &backgroundWG, sessionStore) + startAggregatorTokenRefreshJob(backgroundCtx, &backgroundWG, app.apiKeyService) - // V2.0: Initialize PDS account provisioner for communities (simplified) - // PDS handles all DID and key generation - no Coves-side cryptography needed - provisioner := communities.NewPDSAccountProvisioner(instanceDomain, defaultPDS) - log.Printf("✅ Community provisioner initialized (PDS-managed keys)") - log.Printf(" - Communities will be created at: %s", defaultPDS) - log.Printf(" - PDS will generate and manage all DIDs and keys") - - // Initialize blob upload service (moved earlier for community service) - blobService := blobs.NewBlobService(defaultPDS) - log.Println("✅ Blob service initialized") - - // Initialize community service with OAuth client for user DPoP authentication - // OAuth client is required for subscribe/unsubscribe/block/unblock operations - communityService := communities.NewCommunityService( - communityRepo, - defaultPDS, - instanceDID, - instanceDomain, - provisioner, - oauthClient, - blobService, - ) - - // Authenticate Coves instance with PDS to enable community record writes - // The instance needs a PDS account to write community records it owns - pdsHandle := os.Getenv("PDS_INSTANCE_HANDLE") - pdsPassword := os.Getenv("PDS_INSTANCE_PASSWORD") - if pdsHandle != "" && pdsPassword != "" { - log.Printf("Authenticating Coves instance (%s) with PDS...", instanceDID) - accessToken, authErr := authenticateWithPDS(defaultPDS, pdsHandle, pdsPassword) - if authErr != nil { - log.Printf("Warning: Failed to authenticate with PDS: %v", authErr) - log.Println("Community creation will fail until PDS authentication is configured") - } else { - if svc, ok := communityService.(interface{ SetPDSAccessToken(string) }); ok { - svc.SetPDSAccessToken(accessToken) - log.Println("✓ Coves instance authenticated with PDS") - } - } - } else { - log.Println("Note: PDS_INSTANCE_HANDLE and PDS_INSTANCE_PASSWORD not set") - log.Println("Community creation via write-forward is disabled") - } - - // Jetstream consumer infrastructure: cursor persistence + dead letter queue. - // Cursors let every consumer resume from its last processed event after a - // restart/deploy/crash instead of silently losing the gap; the dead letter - // queue captures events that fail all in-line retries so a background - // redriver can replay them once the failure clears. - jetstreamStateStore := jetstream.NewPostgresStateStore(db) - - // Rev gate: the per-record ordering guard that makes it safe to run every - // consumer against MULTIPLE Jetstream feeds carrying the same repos (see - // rev_gate.go / migration 033). Posts/votes/comments gate inside their own - // transactions; the repo-method consumers get this gate injected. - revGate := jetstream.NewRevGate(db) - - // Feed topology. Each entry is =; every consumer runs once - // per feed with its collection filters appended (see feeds.go). The "bsky" - // feed keeps the legacy consumer names so live cursors carry over. - for _, legacy := range []string{ - "JETSTREAM_URL", "COMMUNITY_JETSTREAM_URL", "POST_JETSTREAM_URL", - "AGGREGATOR_JETSTREAM_URL", "VOTE_JETSTREAM_URL", "COMMENT_JETSTREAM_URL", - } { - if os.Getenv(legacy) != "" { - log.Fatalf("%s is no longer supported: configure feeds via JETSTREAM_FEEDS "+ - "(e.g. \"bsky=wss://jetstream2.us-east.bsky.network;self=ws://tidepool-prod-jetstream:8080\") "+ - "and remove the legacy variable", legacy) - } - } - feedsSpec := os.Getenv("JETSTREAM_FEEDS") - if feedsSpec == "" { - if !isDevEnv { - log.Fatalf("JETSTREAM_FEEDS is required in production (the localhost default is dev-only): " + - "set semicolon-separated = entries, e.g. " + - "\"bsky=wss://jetstream2.us-east.bsky.network;self=ws://tidepool-prod-jetstream:8080\"") - } - // Dev default: the local dev-stack Jetstream only. Production always - // sets JETSTREAM_FEEDS explicitly (see docker-compose.prod.yml). - feedsSpec = "self=ws://localhost:6008" - } - jetstreamFeeds, err := jetstream.ParseFeeds(feedsSpec) + consumers, err := startConsumers(backgroundCtx, &backgroundWG, app) if err != nil { - log.Fatalf("Invalid JETSTREAM_FEEDS: %v", err) - } - hasPrimaryFeed := false - for _, feed := range jetstreamFeeds { - if feed.Key == jetstream.PrimaryFeedKey { - hasPrimaryFeed = true - break - } - } - if !hasPrimaryFeed { - // Expected in local dev (self-only feed); in production this usually - // means cursor continuity from the single-feed era is being forfeited. - log.Printf("⚠️ No JETSTREAM_FEEDS entry uses the primary key %q: every consumer name will be suffixed \"@\", so cursors persisted under the bare legacy names will NOT be used", jetstream.PrimaryFeedKey) - } - - // All consumers run on one cancellable context so SIGTERM drains them: - // read loops unblock, an interrupted in-flight event is abandoned without - // advancing the cursor (it replays idempotently on next boot), and the - // final cursor is flushed. - consumerCtx, consumerCancel := context.WithCancel(context.Background()) - var consumerWG sync.WaitGroup - var jetstreamConnectors []*jetstream.Connector - consumerHandlers := make(map[string]jetstream.EventHandler) - - // startJetstreamConsumer wires a consumer to Jetstream with cursor - // persistence, retry + dead-letter, and graceful shutdown. The name keys - // the persisted cursor and dead letter rows — it must stay stable. - startJetstreamConsumer := func(name, wsURL string, handler jetstream.EventHandler) { - connector := jetstream.NewConnector(name, wsURL, handler, - jetstream.WithCursorStore(jetstreamStateStore), - jetstream.WithDeadLetterWriter(jetstreamStateStore), - ) - jetstreamConnectors = append(jetstreamConnectors, connector) - consumerHandlers[name] = handler - consumerWG.Add(1) - go func() { - defer consumerWG.Done() - if startErr := connector.Start(consumerCtx); startErr != nil && !errors.Is(startErr, context.Canceled) { - log.Printf("Jetstream %s consumer stopped: %v", name, startErr) - } - }() - } - - // registerFeedConsumer defers wiring until every consumer exists; the - // feeds×consumers loop below then starts one connector per (feed, - // consumer) pair. Registration order is preserved. - type feedConsumer struct { - name string - handler jetstream.EventHandler - } - var feedConsumers []feedConsumer - registerFeedConsumer := func(name string, handler jetstream.EventHandler) { - feedConsumers = append(feedConsumers, feedConsumer{name: name, handler: handler}) + // Some connectors may already be running. Drain them under the same + // bounded wait the normal shutdown path uses — an unbounded Wait here + // would hang the boot with no further output if one is wedged. + drainBackground(cfg.Server.ShutdownTimeout, stopBackground, &backgroundWG) + return err } - // Create user consumer with session handle updater to sync OAuth sessions on handle changes - var consumerOpts []jetstream.ConsumerOption + router := newRouter(observability.HTTPMiddleware(otelProvider)) + registerRoutes(router, app, consumers) - // A trusted bridge hosts many virtual repos. Its profile records may be - // the first time Coves sees those identities, and relay scheduling may - // deliver profiles and posts in either order. Share one provenance gate - // across the user, post, and comment consumers. - var trustedBridgePDSHosts []string - if hosts := os.Getenv("TRUSTED_BRIDGE_PDS_HOSTS"); hosts != "" { - for _, h := range strings.Split(hosts, ",") { - if h = strings.TrimSpace(h); h != "" { - trustedBridgePDSHosts = append(trustedBridgePDSHosts, h) - } - } - } - bridgeTrust := jetstream.NewBridgeTrust(trustedBridgePDSHosts) - consumerOpts = append(consumerOpts, jetstream.WithUserBridgeTrust(bridgeTrust)) - consumerOpts = append(consumerOpts, jetstream.WithUserRevGate(revGate)) - if sessionUpdater, ok := baseOAuthStore.(jetstream.SessionHandleUpdater); ok { - consumerOpts = append(consumerOpts, jetstream.WithSessionHandleUpdater(sessionUpdater)) - log.Println("✅ OAuth session handle sync enabled for identity changes") - } - - // Wire user block repo into user consumer for indexing social.coves.actor.block events - userBlockRepo := postgresRepo.NewUserBlockRepository(db) - consumerOpts = append(consumerOpts, jetstream.WithUserBlockRepo(userBlockRepo)) - - userConsumer := jetstream.NewUserEventConsumer(userService, identityResolver, consumerOpts...) - registerFeedConsumer(jetstream.ConsumerUsers, userConsumer) - log.Println("Registered Jetstream user consumer (actor profiles + blocks)") - - // Register Jetstream consumer for community events. This consumer indexes: - // 1. Community profiles (social.coves.community.profile) - in community's own repo - // 2. User subscriptions (social.coves.community.subscription) - in user's repo - // 3. Community blocks (social.coves.community.block) - in user's repo - // (Record-type collections, not XRPC procedures; filters come from - // jetstream.WantedCollections.) - - // Initialize community event consumer with did:web verification - skipDIDWebVerification := os.Getenv("SKIP_DID_WEB_VERIFICATION") == "true" - if skipDIDWebVerification { - log.Println("⚠️ WARNING: did:web domain verification is DISABLED (dev mode)") - log.Println(" Set SKIP_DID_WEB_VERIFICATION=false for production") - } - - // Pass identity resolver to consumer for PLC handle resolution (source of truth) - communityEventConsumer := jetstream.NewCommunityEventConsumer(communityRepo, instanceDID, skipDIDWebVerification, identityResolver, - jetstream.WithCommunityRevGate(revGate)) - registerFeedConsumer(jetstream.ConsumerCommunities, communityEventConsumer) - log.Println("Registered Jetstream community consumer (profiles, subscriptions, blocks)") - - // Start OAuth session cleanup background job with cancellable context - cleanupCtx, cleanupCancel := context.WithCancel(context.Background()) - go func() { - ticker := time.NewTicker(1 * time.Hour) - defer ticker.Stop() - for { - select { - case <-cleanupCtx.Done(): - log.Println("OAuth cleanup job stopped") - return - case <-ticker.C: - // Check if store implements cleanup methods - // Use UnwrapPostgresStore to get the underlying store from the wrapper - if cleanupStore := oauthStore.UnwrapPostgresStore(); cleanupStore != nil { - sessions, sessErr := cleanupStore.CleanupExpiredSessions(cleanupCtx) - if sessErr != nil { - log.Printf("Error cleaning up expired OAuth sessions: %v", sessErr) - } - requests, reqErr := cleanupStore.CleanupExpiredAuthRequests(cleanupCtx) - if reqErr != nil { - log.Printf("Error cleaning up expired OAuth auth requests: %v", reqErr) - } - if sessions > 0 || requests > 0 { - log.Printf("OAuth cleanup: removed %d expired sessions, %d expired auth requests", sessions, requests) - } - } - } - } - }() - - log.Println("Started OAuth session cleanup background job (runs hourly)") - - // Initialize aggregator service - aggregatorRepo := postgresRepo.NewAggregatorRepository(db) - aggregatorService := aggregators.NewAggregatorService(aggregatorRepo, communityService) - log.Println("✅ Aggregator service initialized") - - // Initialize API key service for aggregator authentication - apiKeyService := aggregators.NewAPIKeyService(aggregatorRepo, oauthClient.ClientApp) - log.Println("✅ API key service initialized") + return serve(ctx, cfg, app, router, stopBackground, &backgroundWG) +} - // Start aggregator token refresh background job - // Timing rationale: - // - Runs every 30 minutes to catch tokens before they expire - // - 1-hour expiry buffer ensures we refresh well before expiration - // - This gives us 2 attempts (at 60min and 30min before expiry) to refresh - // - Note: APIKeyService.TokenRefreshBuffer (5min) is for on-demand refresh during API calls, - // while this background job provides proactive refresh for idle aggregators - tokenRefreshCtx, tokenRefreshCancel := context.WithCancel(context.Background()) +// drainBackground stops background work and waits for it to finish, bounded by +// timeout. It reports whether everything drained in time. +// +// The bound matters on every path: a consumer wedged against a dead database +// must not hang the process, and an unbounded Wait would do exactly that with +// no further log output to explain it. +func drainBackground(timeout time.Duration, stopBackground context.CancelFunc, backgroundWG *sync.WaitGroup) bool { + // Cancelling unblocks the Jetstream read loops and flushes their cursors, + // so the next boot resumes from the last processed event (minus a small + // deliberate replay rewind that idempotent handlers absorb). + stopBackground() + + drained := make(chan struct{}) go func() { - defer func() { - if r := recover(); r != nil { - slog.Error("[TOKEN-REFRESH] CRITICAL: Background job panicked", - "panic", r, - ) - } - }() - - ticker := time.NewTicker(30 * time.Minute) - defer ticker.Stop() - - // Heartbeat counter for periodic health logging - cycleCount := 0 - - for { - select { - case <-tokenRefreshCtx.Done(): - slog.Info("[TOKEN-REFRESH] Aggregator token refresh job stopped") - return - case <-ticker.C: - cycleCount++ - refreshed, errs := apiKeyService.RefreshExpiringTokens(tokenRefreshCtx, 1*time.Hour) - if len(errs) > 0 { - slog.Warn("[TOKEN-REFRESH] Aggregator refresh completed with errors", - "refreshed", refreshed, - "failed", len(errs), - ) - for _, err := range errs { - slog.Error("[TOKEN-REFRESH] Refresh error", "error", err) - } - } else if refreshed > 0 { - slog.Info("[TOKEN-REFRESH] Aggregator refresh completed", - "refreshed", refreshed, - ) - } else if cycleCount%6 == 0 { - // Log heartbeat every 6 cycles (3 hours) when no work is done - slog.Info("[TOKEN-REFRESH] Heartbeat: background job running, no tokens needed refresh", - "cycles_completed", cycleCount, - ) - } - } - } + backgroundWG.Wait() + close(drained) }() - log.Println("Started aggregator token refresh background job (runs every 30 minutes)") - - // Get instance DID for service auth validator audience - serviceDID := instanceDID // Use instance DID as the service audience - // Create ServiceAuthValidator for aggregator JWT authentication - // This validates service JWTs signed by aggregator PDSs - serviceValidator := &indigoauth.ServiceAuthValidator{ - Audience: serviceDID, - Dir: identityDir, - TimestampLeeway: 30 * time.Second, + select { + case <-drained: + slog.Info("background jobs drained and Jetstream cursors flushed") + return true + case <-time.After(timeout): + slog.Warn("timed out draining background jobs; Jetstream cursors may not have been flushed", + "timeout", timeout) + return false } - log.Printf("✅ Service auth validator initialized (audience: %s)", serviceDID) - - // Create DualAuthMiddleware that supports OAuth, service JWT, and API keys - // OAuth tokens are for user authentication (sealed session tokens) - // Service JWTs are for aggregator authentication (PDS-signed tokens) - // API keys are for aggregator bot authentication (stateless, cryptographic) - apiKeyValidator := middleware.NewAPIKeyValidatorAdapter(apiKeyService) - dualAuth := middleware.NewDualAuthMiddleware( - oauthClient, // SessionUnsealer for OAuth - oauthStore, // ClientAuthStore for OAuth sessions - serviceValidator, // ServiceAuthValidator for JWT validation - aggregatorRepo, // AggregatorChecker - uses repo directly since it implements the interface - ).WithAPIKeyValidator(apiKeyValidator) - log.Println("✅ Dual auth middleware initialized (OAuth + service JWT + API keys)") - - // Initialize unfurl cache repository - unfurlRepo := unfurl.NewRepository(db) - - // Initialize unfurl service with configuration - unfurlService := unfurl.NewService( - unfurlRepo, - unfurl.WithTimeout(10*time.Second), - unfurl.WithUserAgent("CovesBot/1.0 (+https://coves.social)"), - unfurl.WithCacheTTL(24*time.Hour), - ) - log.Println("✅ Unfurl and blob services initialized") - - // Initialize Bluesky post cache repository and service - // - // Production PLC Read-Only Resolver - // ================================== - // This resolver is used ONLY for resolving real Bluesky handles (e.g., "bretton.dev") - // that exist on the production AT Protocol network. - // - // READ-ONLY GUARANTEE: The identity.Resolver interface only supports read operations: - // - Resolve(), ResolveHandle(), ResolveDID() - HTTP GET lookups only - // - Purge() - clears local cache, does NOT write to PLC - // - // DO NOT use this resolver for: - // - Integration tests (use local PLC at localhost:3002 via identityResolver) - // - Creating/registering new DIDs (handled by separate PLC client) - // - // Safe in dev/test: only performs HTTP GET to resolve existing Bluesky identities. - productionPLCConfig := identity.DefaultConfig() - productionPLCConfig.PLCURL = "https://plc.directory" // Production PLC - READ ONLY - productionPLCResolver := identity.NewResolver(db, productionPLCConfig) - log.Println("✅ Production PLC resolver initialized (READ-ONLY for Bluesky handle resolution)") - - blueskyRepo := blueskypost.NewRepository(db) - blueskyService := blueskypost.NewService( - blueskyRepo, - productionPLCResolver, // READ-ONLY: resolves real Bluesky handles like "bretton.dev" - blueskypost.WithTimeout(10*time.Second), - blueskypost.WithCacheTTL(1*time.Hour), // 1 hour cache (shorter than unfurl) - ) - log.Println("✅ Bluesky post service initialized") - - // Initialize post service (with aggregator support) - postRepo := postgresRepo.NewPostRepository(db) - // userBlockRepo (created above) backs viewer block enforcement on GetPosts, keeping - // permalink/cold-load reads consistent with feed/timeline block filtering. - postService := posts.NewPostService(postRepo, communityService, aggregatorService, blobService, unfurlService, blueskyService, defaultPDS, posts.WithBlockChecker(userBlockRepo)) - - // Initialize vote repository (used by Jetstream consumer for indexing) - voteRepo := postgresRepo.NewVoteRepository(db) - log.Println("✅ Vote repository initialized (Jetstream indexing only)") - - // Initialize comment repository (used by Jetstream consumer for indexing) - commentRepo := postgresRepo.NewCommentRepository(db) - log.Println("✅ Comment repository initialized (Jetstream indexing only)") - - // Initialize vote cache (stores user votes from PDS to avoid eventual consistency issues) - // TTL of 10 minutes - cache is also updated on vote create/delete - voteCache := votes.NewVoteCache(10*time.Minute, nil) - log.Println("✅ Vote cache initialized (10 minute TTL)") - - // Initialize vote service (for XRPC API endpoints) - // Note: We don't validate subject existence - the vote goes to the user's PDS regardless. - // The Jetstream consumer handles orphaned votes correctly by only updating counts for - // non-deleted subjects. This avoids race conditions and eventual consistency issues. - voteService := votes.NewService(voteRepo, oauthClient, oauthStore, voteCache, nil) - log.Println("✅ Vote service initialized (with OAuth authentication and vote cache)") - - // Initialize comment service (for query and write APIs) - // Requires user and community repos for proper author/community hydration per lexicon - // OAuth client and store are needed for write operations (create, update, delete) - commentService := comments.NewCommentService(commentRepo, userRepo, postRepo, communityRepo, oauthClient, oauthStore, nil) - log.Println("✅ Comment service initialized (with author/community hydration and write support)") - - // Initialize user block service (user-to-user blocking) - // userBlockRepo already created above for the Jetstream consumer - userBlockService := userblocks.NewService(userBlockRepo, nil, oauthClient, oauthStore, nil) - log.Println("✅ User block service initialized (with OAuth authentication)") - - // Initialize admin report service (off-protocol reporting for serious content issues) - adminReportRepo := postgresRepo.NewAdminReportRepository(db) - adminReportService := adminreports.NewService(adminReportRepo) - log.Println("✅ Admin report service initialized (for flagging serious content)") - - // Initialize community suggestion service (off-protocol suggestion & voting) - communitySuggestionRepo := postgresRepo.NewCommunitySuggestionRepository(db) - communitySuggestionService := communitysuggestions.NewService(communitySuggestionRepo) - log.Println("✅ Community suggestion service initialized") - - // Initialize feed service - feedRepo := postgresRepo.NewCommunityFeedRepository(db, cursorSecret) - feedService := communityFeeds.NewCommunityFeedService(feedRepo, communityService) - log.Println("✅ Feed service initialized") - - // Initialize timeline service (home feed from subscribed communities) - timelineRepo := postgresRepo.NewTimelineRepository(db, cursorSecret) - timelineService := timeline.NewTimelineService(timelineRepo) - log.Println("✅ Timeline service initialized") - - // Initialize discover service (public feed from all communities) - discoverRepo := postgresRepo.NewDiscoverRepository(db, cursorSecret) - discoverService := discover.NewDiscoverService(discoverRepo) - log.Println("✅ Discover service initialized") - - // Initialize image proxy (optional service for resizing/caching images) - imageProxyConfig := imageproxy.ConfigFromEnv() - var imageProxyCacheCleanupCancel context.CancelFunc = func() {} // No-op default - if imageProxyConfig.Enabled { - // Validate configuration at startup - fail fast if misconfigured - if err := imageProxyConfig.Validate(); err != nil { - log.Fatalf("Image proxy configuration error: %v", err) - } +} - imageProxyCache, err := imageproxy.NewDiskCache( - imageProxyConfig.CachePath, - imageProxyConfig.CacheMaxGB, - imageProxyConfig.CacheTTLDays, +// serve runs the HTTP listener until ctx is cancelled, then shuts everything +// down within cfg.Server.ShutdownTimeout. +func serve( + ctx context.Context, + cfg *config.Config, + app *application, + handler http.Handler, + stopBackground context.CancelFunc, + backgroundWG *sync.WaitGroup, +) error { + server := newHTTPServer(cfg.Server, handler) + + // Buffered so a listener that fails after shutdown has begun does not + // leak this goroutine on an unread channel. + listenErr := make(chan error, 1) + go func() { + slog.Info("Coves AppView listening", + "port", cfg.Server.Port, + "pds", cfg.PDS.URL, + "instance_did", cfg.Instance.DID, ) - if err != nil { - log.Fatalf("Failed to create image proxy cache: %v", err) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + listenErr <- err + return } + listenErr <- nil + }() - // Start background cache cleanup job - imageProxyCacheCleanupCancel = imageProxyCache.StartCleanupJob(imageProxyConfig.CleanupInterval) - - imageProxyProcessor := imageproxy.NewProcessor() - imageProxyFetcher := imageproxy.NewPDSFetcher(imageProxyConfig.FetchTimeout, imageProxyConfig.MaxSourceSizeMB) - imageProxyService, err := imageproxy.NewService( - imageProxyCache, - imageProxyProcessor, - imageProxyFetcher, - imageProxyConfig, - ) + var listenFailure error + select { + case err := <-listenErr: if err != nil { - log.Fatalf("Failed to create image proxy service: %v", err) - } - imageProxyHandler := imageproxyhandlers.NewHandler(imageProxyService, identityResolver) - routes.RegisterImageProxyRoutes(r, imageProxyHandler) - log.Println("✅ Image proxy enabled at /img/{preset}/plain/{did}/{cid}") - slog.Info("[IMAGE-PROXY] service started", - "base_url", imageProxyConfig.BaseURL, - "cdn_url", imageProxyConfig.CDNURL, - "cache_path", imageProxyConfig.CachePath, - "cache_max_gb", imageProxyConfig.CacheMaxGB, - "cache_ttl_days", imageProxyConfig.CacheTTLDays, - "cleanup_interval", imageProxyConfig.CleanupInterval, - "fetch_timeout_seconds", int(imageProxyConfig.FetchTimeout.Seconds()), - "max_source_size_mb", imageProxyConfig.MaxSourceSizeMB, - ) - } - - // Initialize image proxy config for URL generation in communities package - // This is called once at startup and is thread-safe for concurrent access - communities.SetImageProxyConfig(blobs.ImageURLConfig{ - ProxyEnabled: imageProxyConfig.Enabled, - ProxyBaseURL: imageProxyConfig.BaseURL, - CDNURL: imageProxyConfig.CDNURL, - }) - log.Printf("Image proxy URL generation config set (enabled: %v)", imageProxyConfig.Enabled) - - // Register Jetstream consumer for posts - // This consumer indexes posts created in community repositories via the firehose - - // Provenance gate for bridge-asserted vote aggregates (bridgedStats). Only records - // whose repo is hosted on a trusted bridge PDS may inflate their displayed vote - // counts/score; every other (native) repo is default-denied so it cannot self-assert - // bridgedStats. Configured via TRUSTED_BRIDGE_PDS_HOSTS (comma-separated PDS host - // URLs), mirroring the COMMUNITY_CREATORS allowlist convention. Empty => bridgedStats - // are universally ignored (safe default for deployments with no bridge). - if len(trustedBridgePDSHosts) > 0 { - log.Printf("bridgedStats provenance: trusting %d bridge PDS host(s)", len(trustedBridgePDSHosts)) - } else { - log.Println("bridgedStats provenance: no trusted bridge PDS hosts configured; bridgedStats will be ignored") - } - - postEventConsumer := jetstream.NewPostEventConsumer(postRepo, communityRepo, userService, db, - jetstream.WithPostBridgeTrust(bridgeTrust), - jetstream.WithPostIdentityResolver(identityResolver)) - registerFeedConsumer(jetstream.ConsumerPosts, postEventConsumer) - log.Println("Registered Jetstream post consumer (CREATE/UPDATE/DELETE)") - - // Register Jetstream consumer for aggregators: service declarations and - // authorization records, following Bluesky's feed generator/labeler pattern - aggregatorEventConsumer := jetstream.NewAggregatorEventConsumer(aggregatorRepo, - jetstream.WithAggregatorRevGate(revGate)) - registerFeedConsumer(jetstream.ConsumerAggregators, aggregatorEventConsumer) - log.Println("Registered Jetstream aggregator consumer (services + authorizations)") - - // Register Jetstream consumer for votes: indexes votes from user - // repositories and updates post/comment vote counts atomically - voteEventConsumer := jetstream.NewVoteEventConsumer(voteRepo, userService, db) - registerFeedConsumer(jetstream.ConsumerVotes, voteEventConsumer) - log.Println("Registered Jetstream vote consumer (CREATE/DELETE + count updates)") - - // Register Jetstream consumer for comments: indexes comments from user - // repositories and updates parent post/comment counts atomically - commentEventConsumer := jetstream.NewCommentEventConsumer(commentRepo, db, - jetstream.WithCommentBridgeTrust(bridgeTrust)) - registerFeedConsumer(jetstream.ConsumerComments, commentEventConsumer) - log.Println("Registered Jetstream comment consumer (CREATE/UPDATE/DELETE + count updates)") - - // FAIL CLOSED: with more than one feed, every consumer MUST be rev-gated — - // an ungated consumer would apply the lagging feed's stale copies (zombie - // deletes, regressed edits), which is silent data corruption, not a - // degraded mode. A forgotten WithXRevGate option must stop the boot, not - // ship the bug. - if len(jetstreamFeeds) > 1 { - for _, fc := range feedConsumers { - gated, ok := fc.handler.(interface{ RevGated() bool }) - if !ok || !gated.RevGated() { - log.Fatalf("consumer %q is not rev-gated but %d Jetstream feeds are configured; "+ - "multi-feed operation requires every consumer to carry the rev gate (see rev_gate.go)", - fc.name, len(jetstreamFeeds)) - } - } - } - - // Start every registered consumer on every configured feed. Consumer names - // on the primary ("bsky") feed stay bare so live cursors carry over from - // the single-feed era; other feeds get "@" names, which - // start cursor-less and live-tail (recovering older records requires the - // source PDSes to re-emit them — see Tidepool's POST /admin/reemit). - // Rev-gating makes the cross-feed overlap safe; expect "rev-gate: skipped - // stale" log lines for the lagging feed's copies — that is the system - // working, not an error. - for _, feed := range jetstreamFeeds { - for _, fc := range feedConsumers { - collections, collectionsErr := jetstream.WantedCollections(fc.name) - if collectionsErr != nil { - log.Fatalf("Failed to resolve wantedCollections for consumer %s: %v", fc.name, collectionsErr) - } - wsURL, urlErr := jetstream.SubscribeURL(feed.BaseURL, collections) - if urlErr != nil { - log.Fatalf("Failed to build Jetstream URL for consumer %s on feed %s: %v", fc.name, feed.Key, urlErr) - } - startJetstreamConsumer(jetstream.FeedConsumerName(fc.name, feed.Key), wsURL, fc.handler) + // Still fall through to the drain: consumers are already running + // and hold cursors that must be flushed before run's deferred + // db.Close fires. + listenFailure = fmt.Errorf("HTTP server: %w", err) + slog.Error("HTTP listener failed; draining background work", "error", err) } - log.Printf("Started %d Jetstream consumers on feed %q (%s)", len(feedConsumers), feed.Key, feed.BaseURL) + case <-ctx.Done(): + slog.Info("shutdown signal received") } - // Start the dead letter redriver: replays events that failed all in-line - // retries against the same consumers, so transient failures (e.g. a - // Postgres blip) self-heal instead of silently losing the event. - deadLetterRedriver := jetstream.NewDeadLetterRedriver(jetstreamStateStore, consumerHandlers) - consumerWG.Add(1) + // Drain background work concurrently with the listener drain, not after + // it. They are independent — background work runs on its own context, + // while in-flight requests carry contexts owned by the server — so + // serialising them would make one shared deadline cover two waits, and a + // slow request (WriteTimeout allows 120s) could consume the entire budget + // before consumers were even told to stop, leaving cursors unflushed. + drainResult := make(chan bool, 1) go func() { - defer consumerWG.Done() - deadLetterRedriver.Run(consumerCtx) + drainResult <- drainBackground(cfg.Server.ShutdownTimeout, stopBackground, backgroundWG) }() - log.Println("Started Jetstream dead letter redriver") - - // Register XRPC routes - routes.RegisterUserRoutesWithOptions(r, userService, authMiddleware, oauthClient.ClientApp, &routes.UserRouteOptions{ - UserBlockRepo: userBlockRepo, - }) - log.Println("User XRPC endpoints registered") - log.Println(" - GET /xrpc/social.coves.actor.getProfile (public, OptionalAuth for viewer.blocking)") - log.Println(" - POST /xrpc/social.coves.actor.signup (public)") - log.Println(" - POST /xrpc/social.coves.actor.requestSignupToken (public, Turnstile-gated, 5 req/min per IP)") - log.Println(" - POST /xrpc/social.coves.actor.deleteAccount (requires OAuth)") - log.Println(" - POST /xrpc/social.coves.actor.updateProfile (requires OAuth)") - - routes.RegisterCommunityRoutes(r, communityService, communityRepo, authMiddleware, allowedCommunityCreators) - log.Println("Community XRPC endpoints registered with OAuth authentication") - - routes.RegisterPostRoutes(r, postService, voteService, blueskyService, dualAuth, authMiddleware) - log.Println("Post XRPC endpoints registered with dual auth (OAuth + service JWT for aggregators)") - - routes.RegisterVoteRoutes(r, voteService, authMiddleware) - log.Println("Vote XRPC endpoints registered with OAuth authentication") - - routes.RegisterUserBlockRoutes(r, userBlockService, authMiddleware) - log.Println("User block XRPC endpoints registered with OAuth authentication") - log.Println(" - POST /xrpc/social.coves.actor.blockUser") - log.Println(" - POST /xrpc/social.coves.actor.unblockUser") - log.Println(" - GET /xrpc/social.coves.actor.getBlockedUsers") - - // Register comment write routes (create, update, delete) - routes.RegisterCommentRoutes(r, commentService, authMiddleware) - log.Println("Comment write XRPC endpoints registered") - log.Println(" - POST /xrpc/social.coves.community.comment.create") - log.Println(" - POST /xrpc/social.coves.community.comment.update") - log.Println(" - POST /xrpc/social.coves.community.comment.delete") - - // Register admin report routes (off-protocol content flagging) - routes.RegisterAdminReportRoutes(r, adminReportService, authMiddleware) - log.Println("✅ Admin report endpoint registered (requires OAuth)") - log.Println(" - POST /xrpc/social.coves.admin.submitReport") - - // Register community suggestion routes (off-protocol suggestion & voting) - routes.RegisterCommunitySuggestionRoutes(r, communitySuggestionService, authMiddleware, allowedCommunityCreators) - log.Println("Community suggestion endpoints registered (off-protocol)") - log.Println(" - POST /xrpc/social.coves.community.suggestion.create (requires OAuth, rate limited)") - log.Println(" - GET /xrpc/social.coves.community.suggestion.list (optional auth)") - log.Println(" - GET /xrpc/social.coves.community.suggestion.get (optional auth)") - log.Println(" - POST /xrpc/social.coves.community.suggestion.vote (requires OAuth)") - log.Println(" - POST /xrpc/social.coves.community.suggestion.removeVote (requires OAuth)") - log.Println(" - POST /xrpc/social.coves.community.suggestion.updateStatus (admin only)") - - routes.RegisterCommunityFeedRoutes(r, feedService, voteService, blueskyService, authMiddleware) - log.Println("Feed XRPC endpoints registered (public with optional auth for viewer vote state)") - - routes.RegisterTimelineRoutes(r, timelineService, voteService, blueskyService, authMiddleware) - log.Println("Timeline XRPC endpoints registered (requires authentication, includes viewer vote state)") - - routes.RegisterDiscoverRoutes(r, discoverService, voteService, blueskyService, authMiddleware) - log.Println("Discover XRPC endpoints registered (public with optional auth for viewer vote state)") - - routes.RegisterActorRoutes(r, postService, userService, voteService, blueskyService, commentService, authMiddleware) - log.Println("Actor XRPC endpoints registered (public with optional auth for viewer vote state)") - log.Println(" - GET /xrpc/social.coves.actor.getPosts") - log.Println(" - GET /xrpc/social.coves.actor.getComments") - - routes.RegisterAggregatorRoutes(r, aggregatorService, communityService, userService, identityResolver) - log.Println("Aggregator XRPC endpoints registered (query endpoints public, registration endpoint public)") - - routes.RegisterAggregatorAPIKeyRoutes(r, authMiddleware, apiKeyService, aggregatorService) - log.Println("✅ Aggregator API key endpoints registered") - log.Println(" - POST /xrpc/social.coves.aggregator.createApiKey (requires OAuth)") - log.Println(" - GET /xrpc/social.coves.aggregator.getApiKey (requires OAuth)") - log.Println(" - POST /xrpc/social.coves.aggregator.revokeApiKey (requires OAuth)") - log.Println(" - GET /xrpc/social.coves.aggregator.getMetrics (public)") - - // Comment query API - supports optional authentication for viewer state - // Stricter rate limiting for expensive nested comment queries - commentRateLimiter := middleware.NewRateLimiter(20, 1*time.Minute) - commentServiceAdapter := commentsAPI.NewServiceAdapter(commentService) - commentHandler := commentsAPI.NewGetCommentsHandler(commentServiceAdapter) - r.Handle( - "/xrpc/social.coves.community.comment.getComments", - commentRateLimiter.Middleware( - commentsAPI.OptionalAuthMiddleware(authMiddleware, commentHandler.HandleGetComments), - ), - ) - log.Println("✅ Comment query API registered (20 req/min rate limit)") - log.Println(" - GET /xrpc/social.coves.community.comment.getComments") - - // Configure allowed CORS origins for OAuth callback - // SECURITY: Never use wildcard "*" with credentials - only allow specific origins - var oauthAllowedOrigins []string - appviewPublicURL := os.Getenv("APPVIEW_PUBLIC_URL") - if appviewPublicURL == "" { - appviewPublicURL = "http://localhost:8080" - } - oauthAllowedOrigins = append(oauthAllowedOrigins, appviewPublicURL) - - // In dev mode, also allow common localhost origins for testing - if oauthConfig.DevMode { - oauthAllowedOrigins = append(oauthAllowedOrigins, - "http://localhost:3000", - "http://localhost:3001", - "http://localhost:5173", - "http://127.0.0.1:8080", - "http://127.0.0.1:3000", - "http://127.0.0.1:3001", - "http://127.0.0.1:5173", - ) - log.Printf("🧪 DEV MODE: OAuth CORS allows localhost origins for testing") - } - log.Printf("OAuth CORS allowed origins: %v", oauthAllowedOrigins) - // Register OAuth routes for authentication flow - routes.RegisterOAuthRoutes(r, oauthHandler, oauthAllowedOrigins) - log.Println("✅ OAuth endpoints registered") - log.Println(" - GET /oauth/client-metadata.json") - log.Println(" - GET /oauth/jwks.json") - log.Println(" - GET /oauth/login") - log.Println(" - GET /oauth/mobile/login") - log.Println(" - GET /oauth/callback") - log.Println(" - POST /oauth/logout") - log.Println(" - POST /oauth/refresh") - - // Register well-known routes for mobile app deep linking - routes.RegisterWellKnownRoutes(r) - log.Println("✅ Well-known endpoints registered (mobile Universal Links & App Links)") - log.Println(" - GET /.well-known/apple-app-site-association (iOS Universal Links)") - log.Println(" - GET /.well-known/assetlinks.json (Android App Links)") - - // Register web frontend routes (landing page, account deletion) - routes.RegisterWebRoutes(r, oauthClient, userService, turnstileSiteKey) - log.Println("✅ Web frontend routes registered") - log.Println(" - GET / (landing page)") - log.Println(" - GET /delete-account (account deletion page)") - log.Println(" - POST /delete-account (delete account)") - log.Println(" - GET /delete-account/success (deletion success)") - log.Println(" - GET /m/turnstile.html (mobile WebView Turnstile widget)") - log.Println(" - GET /static/* (static assets)") + shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout) + defer cancel() - // Health check endpoints - // /health and /xrpc/_health stay pure liveness checks (Docker healthcheck - // targets) — a Jetstream outage must not restart-loop the whole AppView. - healthHandler := func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - if _, err := w.Write([]byte("OK")); err != nil { - log.Printf("Failed to write health check response: %v", err) + var shutdownErr error + if listenFailure == nil { + if err := server.Shutdown(shutdownCtx); err != nil { + // Deliberately not returned early: that would abandon the drain, + // and a lost cursor flush costs more than a few abandoned + // in-flight requests. + shutdownErr = fmt.Errorf("HTTP server shutdown: %w", err) + slog.Error("HTTP server shutdown error", "error", err) } } - r.Get("/health", healthHandler) - r.Get("/xrpc/_health", healthHandler) - // /health/consumers reports indexing health: per-consumer connection - // state, cursor position, and dead letter backlog. Returns 503 when any - // consumer has been disconnected past the stalled threshold (see - // consumerHealthHandler), so monitoring can alert on stalled indexing - // even while the HTTP server is fine. - r.Get("/health/consumers", consumerHealthHandler(jetstreamConnectors, jetstreamStateStore)) + drained := <-drainResult - // Check PORT first (docker-compose), then APPVIEW_PORT (legacy) - port := os.Getenv("PORT") - if port == "" { - port = os.Getenv("APPVIEW_PORT") - } - if port == "" { - port = "8080" - } + // Stop the image proxy cleanup job here rather than leaving it to run's + // deferred Close, so it does not outlive the drain. Close is idempotent, + // so the deferred call is a no-op. + app.Close() - // Create HTTP server for graceful shutdown - server := &http.Server{ - Addr: ":" + port, - Handler: r, + if !drained { + shutdownErr = errors.Join(shutdownErr, + fmt.Errorf("background jobs did not drain within %s", cfg.Server.ShutdownTimeout)) } - // Channel to listen for shutdown signals - stop := make(chan os.Signal, 1) - signal.Notify(stop, os.Interrupt, syscall.SIGTERM) - - // Start server in goroutine - go func() { - fmt.Printf("Coves AppView starting on port %s\n", port) - fmt.Printf("Default PDS: %s\n", defaultPDS) - if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("Server error: %v", err) - } - }() - - // Wait for shutdown signal - <-stop - log.Println("Shutting down server...") - - // Graceful shutdown with timeout - shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // Stop background jobs - cleanupCancel() - tokenRefreshCancel() - imageProxyCacheCleanupCancel() - - // Drain Jetstream consumers: cancelling the context unblocks their read - // loops and flushes cursors so the next boot resumes from the last - // processed event (minus a small deliberate replay rewind; idempotent - // handlers absorb the overlap). - consumerCancel() - consumersDrained := make(chan struct{}) - go func() { - consumerWG.Wait() - close(consumersDrained) - }() - - // Never log.Fatalf here: that would skip the consumer drain below and the - // final cursor flush. Deferred cleanups (DB close, OTel flush) also still - // need to run, so log the failure and return normally instead of exiting - // non-zero via os.Exit (which would skip those defers). - shutdownFailed := false - if err := server.Shutdown(shutdownCtx); err != nil { - shutdownFailed = true - log.Printf("Server shutdown error: %v", err) - } - - select { - case <-consumersDrained: - log.Println("Jetstream consumers drained and cursors flushed") - case <-shutdownCtx.Done(): - log.Println("Timed out waiting for Jetstream consumers to drain") + // Report shutdown problems through the exit code. Logging them and + // returning nil made an abandoned drain look like a clean stop to + // Docker and systemd, which is exactly the signal an operator needs. + if err := errors.Join(listenFailure, shutdownErr); err != nil { + return err } - if shutdownFailed { - log.Println("Server stopped with shutdown errors") - } else { - log.Println("Server stopped gracefully") - } -} - -// consumerStalledThreshold is how long a consumer may be disconnected before -// /health/consumers reports "stalled" (503). -const consumerStalledThreshold = 60 * time.Second - -// consumerHealth is one consumer's entry in the /health/consumers response. -// -// LastEventAgeSeconds and CursorAgeSeconds are informational signals for -// operator alerting, NOT auto-503 inputs: a quiet local stream legitimately -// receives no events, so a large age alone cannot distinguish "nothing to -// index" from "connected but wedged". Operators who know their stream's -// expected cadence can alert on these externally. -type consumerHealth struct { - jetstream.ConnectorStatus - DeadLetterBacklog int64 `json:"deadLetterBacklog"` - LastEventAgeSeconds *int64 `json:"lastEventAgeSeconds,omitempty"` // omitted if no event received yet - CursorAgeSeconds *int64 `json:"cursorAgeSeconds,omitempty"` // omitted if the cursor is still 0 -} - -// consumerHealthResponse is the /health/consumers response body. -type consumerHealthResponse struct { - Status string `json:"status"` // "ok", "degraded", or "stalled" - // DeadLetterBacklogUnknown distinguishes "backlog is 0" from "the backlog - // could not be counted" (e.g. Postgres is down): without it the endpoint - // would look healthier the sicker the database gets. - DeadLetterBacklogUnknown bool `json:"deadLetterBacklogUnknown,omitempty"` - Consumers []consumerHealth `json:"consumers"` + slog.Info("server stopped gracefully") + return nil } -// buildConsumerHealthResponse is the pure decision core of /health/consumers, -// extracted so tests can drive it with hand-built statuses. Rules: -// - any consumer disconnected longer than consumerStalledThreshold → -// "stalled" + 503. A connector that has never connected reports no -// DisconnectedSince and is NOT stalled (boot grace: consumers start -// alongside the HTTP server and need a moment to connect). -// - dead letter backlog uncountable → "degraded" + 200 (stalled wins). -// - otherwise "ok" + 200. -func buildConsumerHealthResponse(statuses []jetstream.ConnectorStatus, backlogs map[string]int64, backlogUnknown bool, now time.Time) (consumerHealthResponse, int) { - response := consumerHealthResponse{Status: "ok"} - httpCode := http.StatusOK - if backlogUnknown { - response.Status = "degraded" - response.DeadLetterBacklogUnknown = true +// startObservability initializes optional OpenTelemetry tracing. Tracing being +// disabled is the normal case and returns a working no-op provider. +func startObservability(ctx context.Context) (*observability.Provider, error) { + otelConfig := observability.ConfigFromEnv() + if err := otelConfig.Validate(); err != nil { + return nil, fmt.Errorf("invalid OpenTelemetry configuration: %w", err) } - for _, status := range statuses { - if !status.Connected && status.DisconnectedSince != nil && - now.Sub(*status.DisconnectedSince) > consumerStalledThreshold { - response.Status = "stalled" - httpCode = http.StatusServiceUnavailable - } - - entry := consumerHealth{ - ConnectorStatus: status, - DeadLetterBacklog: backlogs[status.Name], - } - if status.LastEventAt != nil { - age := int64(now.Sub(*status.LastEventAt).Seconds()) - entry.LastEventAgeSeconds = &age - } - if status.CursorTimeUS != 0 { - age := int64(now.Sub(time.UnixMicro(status.CursorTimeUS)).Seconds()) - entry.CursorAgeSeconds = &age - } - response.Consumers = append(response.Consumers, entry) + provider, err := observability.NewProvider(ctx, otelConfig) + if err != nil { + return nil, fmt.Errorf("initializing OpenTelemetry: %w", err) } - return response, httpCode -} - -// consumerHealthHandler reports Jetstream consumer health as JSON: connection -// state, cursor position, processed/dead-lettered counts, event/cursor ages, -// and the dead letter backlog per consumer. Responds 503 when any consumer -// has been disconnected longer than consumerStalledThreshold (indexing is -// stalled) so monitoring can alert on it. -func consumerHealthHandler(connectors []*jetstream.Connector, deadLetterQueue jetstream.DeadLetterQueue) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - backlogs, err := deadLetterQueue.CountDeadLetters(r.Context()) - backlogUnknown := err != nil - if backlogUnknown { - // Log the error server-side only: this is a public endpoint, so - // the response carries just the deadLetterBacklogUnknown flag. - log.Printf("Failed to count dead letters for health check: %v", err) - } - - statuses := make([]jetstream.ConnectorStatus, 0, len(connectors)) - for _, connector := range connectors { - statuses = append(statuses, connector.Status()) - } - - response, httpCode := buildConsumerHealthResponse(statuses, backlogs, backlogUnknown, time.Now()) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(httpCode) - if err := json.NewEncoder(w).Encode(response); err != nil { - log.Printf("Failed to write consumer health response: %v", err) - } + if otelConfig.Enabled { + slog.Info("OpenTelemetry tracing enabled", "endpoint", otelConfig.Endpoint) } + return provider, nil } -// authenticateWithPDS creates a session on the PDS and returns an access token -func authenticateWithPDS(pdsURL, handle, password string) (string, error) { - type CreateSessionRequest struct { - Identifier string `json:"identifier"` - Password string `json:"password"` +// logStartupWarnings reports configuration that is valid but degraded, so an +// operator sees why a feature is unavailable instead of discovering it through +// a 503 later. +func logStartupWarnings(cfg *config.Config) { + if cfg.IsDevEnv { + slog.Warn("running in DEV mode: production safety checks are relaxed") } - type CreateSessionResponse struct { - DID string `json:"did"` - Handle string `json:"handle"` - AccessJwt string `json:"accessJwt"` + if cfg.OAuth.SealSecretGenerated { + slog.Warn("OAUTH_SEAL_SECRET is unset: generated a random seal secret, " + + "so every restart signs out all users") } - reqBody, err := json.Marshal(CreateSessionRequest{ - Identifier: handle, - Password: password, - }) - if err != nil { - return "", fmt.Errorf("failed to marshal request: %w", err) + // Signup stays gated by the PDS's own PDS_INVITE_REQUIRED, so missing + // config here closes signup rather than leaving it unprotected. + if !cfg.Signup.TokenEndpointEnabled(cfg.PDS.AdminPassword) { + slog.Warn("signup-token endpoint DISABLED: new signups are blocked", + "turnstile_secret_set", cfg.Signup.TurnstileSecretKey != "", + "pds_admin_password_set", cfg.PDS.AdminPassword != "", + ) } - - resp, err := http.Post( - pdsURL+"/xrpc/com.atproto.server.createSession", - "application/json", - bytes.NewReader(reqBody), - ) - if err != nil { - return "", fmt.Errorf("failed to call PDS: %w", err) + if cfg.Signup.TurnstileSiteKey == "" { + slog.Warn("TURNSTILE_SITE_KEY unset: /m/turnstile.html is disabled and mobile signup " + + "will fail at the captcha step") } - defer func() { - if closeErr := resp.Body.Close(); closeErr != nil { - log.Printf("Failed to close response body: %v", closeErr) - } - }() - if resp.StatusCode != http.StatusOK { - body, readErr := io.ReadAll(resp.Body) - if readErr != nil { - return "", fmt.Errorf("PDS returned status %d and failed to read body: %w", resp.StatusCode, readErr) - } - return "", fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, string(body)) - } - - var session CreateSessionResponse - if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { - return "", fmt.Errorf("failed to decode response: %w", err) + if len(cfg.Instance.AllowedCommunityCreators) > 0 { + slog.Info("community creation restricted to an allowlist", + "allowed_dids", len(cfg.Instance.AllowedCommunityCreators)) + } else { + slog.Info("community creation is open to all authenticated users") } - return session.AccessJwt, nil + slog.Info("instance identity resolved", + "instance_did", cfg.Instance.DID, + "instance_domain", cfg.Instance.Domain, + ) } diff --git a/cmd/server/pds.go b/cmd/server/pds.go new file mode 100644 index 0000000..61d02f4 --- /dev/null +++ b/cmd/server/pds.go @@ -0,0 +1,91 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "time" +) + +// pdsAuthTimeout bounds the instance's own PDS login at startup. +// +// Without it this used http.Post, which uses http.DefaultClient — and +// http.DefaultClient has no timeout at all. A PDS that accepted the connection +// but never responded would hang the boot indefinitely, with the process +// looking alive to the orchestrator but serving nothing. +const pdsAuthTimeout = 15 * time.Second + +// maxPDSErrorBodyBytes bounds how much of a PDS error body is read into the +// returned error, so a misbehaving server cannot force an unbounded read. +const maxPDSErrorBodyBytes = 4 << 10 + +// authenticateWithPDS creates a session on the PDS and returns an access +// token. The instance needs its own PDS account to write the community records +// it owns. +func authenticateWithPDS(ctx context.Context, pdsURL, handle, password string) (string, error) { + type createSessionRequest struct { + Identifier string `json:"identifier"` + Password string `json:"password"` + } + + type createSessionResponse struct { + DID string `json:"did"` + Handle string `json:"handle"` + AccessJwt string `json:"accessJwt"` + } + + reqBody, err := json.Marshal(createSessionRequest{ + Identifier: handle, + Password: password, + }) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + ctx, cancel := context.WithTimeout(ctx, pdsAuthTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + pdsURL+"/xrpc/com.atproto.server.createSession", + bytes.NewReader(reqBody)) + if err != nil { + return "", fmt.Errorf("failed to build PDS request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to call PDS: %w", err) + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + slog.Error("failed to close PDS response body", "error", closeErr) + } + }() + + if resp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxPDSErrorBodyBytes)) + if readErr != nil { + return "", fmt.Errorf("PDS returned status %d and failed to read body: %w", resp.StatusCode, readErr) + } + // The password is never echoed back by createSession, so the body is + // safe to surface here; it is the only useful diagnostic for a + // misconfigured instance account. + return "", fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, string(body)) + } + + var session createSessionResponse + if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + if session.AccessJwt == "" { + return "", errors.New("PDS returned a session with no access token") + } + + return session.AccessJwt, nil +} diff --git a/cmd/server/routes.go b/cmd/server/routes.go new file mode 100644 index 0000000..8eef425 --- /dev/null +++ b/cmd/server/routes.go @@ -0,0 +1,173 @@ +package main + +import ( + "Coves/internal/api/middleware" + "Coves/internal/api/routes" + "log/slog" + "net/http" + "time" + + commentsAPI "Coves/internal/api/handlers/comments" + + "github.com/go-chi/chi/v5" + chiMiddleware "github.com/go-chi/chi/v5/middleware" +) + +const ( + // globalRateLimit and globalRateWindow bound every request by client IP. + // Per-route limiters layer stricter caps on the expensive and + // abuse-prone endpoints. + globalRateLimit = 100 + globalRateWindow = time.Minute + + // Nested comment queries fan out across the comment tree, so they carry a + // tighter cap than the global one. + commentQueryRateLimit = 20 + commentQueryRateWindow = time.Minute +) + +// newRouter builds the chi router with the middleware stack every request +// passes through. Order matters: RequestID and Recoverer must wrap the rate +// limiter so a rejected request is still logged and a panic in the limiter +// cannot take down the process. +func newRouter(otelMiddleware func(http.Handler) http.Handler) chi.Router { + r := chi.NewRouter() + + r.Use(chiMiddleware.RequestID) + r.Use(chiMiddleware.Logger) + r.Use(chiMiddleware.Recoverer) + r.Use(middleware.NewNamedRateLimiter("global", globalRateLimit, globalRateWindow).Middleware) + + if otelMiddleware != nil { + r.Use(otelMiddleware) + } + + return r +} + +// registerRoutes mounts every HTTP endpoint the server serves. +// +// Consumers are started before this runs, so /health/consumers can be bound +// directly to the live connector set instead of reading it through shared +// mutable state. +func registerRoutes(r chi.Router, app *application, consumers *consumerSet) { + registerXRPCRoutes(r, app) + registerOAuthRoutes(r, app) + registerWebRoutes(r, app) + registerHealthRoutes(r, app, consumers) + + if app.imageProxyHandler != nil { + routes.RegisterImageProxyRoutes(r, app.imageProxyHandler) + slog.Info("registered image proxy route", "path", "/img/{preset}/plain/{did}/{cid}") + } +} + +// registerXRPCRoutes mounts the atProto XRPC surface. +func registerXRPCRoutes(r chi.Router, app *application) { + routes.RegisterUserRoutesWithOptions(r, app.userService, app.authMiddleware, + app.oauthClient.ClientApp, &routes.UserRouteOptions{UserBlockRepo: app.userBlockRepo}) + + routes.RegisterCommunityRoutes(r, app.communityService, app.communityRepo, + app.authMiddleware, app.cfg.Instance.AllowedCommunityCreators) + + // Posts accept dual auth so aggregator bots can publish with a service + // JWT or API key rather than a user's OAuth session. + routes.RegisterPostRoutes(r, app.postService, app.voteService, app.blueskyService, + app.dualAuth, app.authMiddleware) + + routes.RegisterVoteRoutes(r, app.voteService, app.authMiddleware) + routes.RegisterUserBlockRoutes(r, app.userBlockService, app.authMiddleware) + routes.RegisterCommentRoutes(r, app.commentService, app.authMiddleware) + routes.RegisterAdminReportRoutes(r, app.adminReportService, app.authMiddleware) + routes.RegisterCommunitySuggestionRoutes(r, app.communitySuggestionService, + app.authMiddleware, app.cfg.Instance.AllowedCommunityCreators) + + // Feed, timeline, discover, and actor routes take optional auth: they are + // public, but an authenticated viewer additionally gets vote state and + // block filtering. + routes.RegisterCommunityFeedRoutes(r, app.feedService, app.voteService, + app.blueskyService, app.authMiddleware) + routes.RegisterTimelineRoutes(r, app.timelineService, app.voteService, + app.blueskyService, app.authMiddleware) + routes.RegisterDiscoverRoutes(r, app.discoverService, app.voteService, + app.blueskyService, app.authMiddleware) + routes.RegisterActorRoutes(r, app.postService, app.userService, app.voteService, + app.blueskyService, app.commentService, app.authMiddleware) + + routes.RegisterAggregatorRoutes(r, app.aggregatorService, app.communityService, + app.userService, app.identityResolver) + routes.RegisterAggregatorAPIKeyRoutes(r, app.authMiddleware, app.apiKeyService, app.aggregatorService) + + registerCommentQueryRoute(r, app) + + slog.Info("XRPC endpoints registered") +} + +// registerCommentQueryRoute mounts the comment tree query separately because +// it needs both optional authentication (for viewer vote state) and a rate +// limit stricter than the global one. +func registerCommentQueryRoute(r chi.Router, app *application) { + limiter := middleware.NewNamedRateLimiter("commentQuery", + commentQueryRateLimit, commentQueryRateWindow) + handler := commentsAPI.NewGetCommentsHandler(commentsAPI.NewServiceAdapter(app.commentService)) + + r.Handle( + "/xrpc/social.coves.community.comment.getComments", + limiter.Middleware( + commentsAPI.OptionalAuthMiddleware(app.authMiddleware, handler.HandleGetComments), + ), + ) +} + +// registerOAuthRoutes mounts the OAuth authentication flow. +func registerOAuthRoutes(r chi.Router, app *application) { + routes.RegisterOAuthRoutes(r, app.oauthHandler, oauthAllowedOrigins(app)) + slog.Info("OAuth endpoints registered") +} + +// oauthAllowedOrigins lists the origins permitted to make credentialed +// cross-origin requests to the OAuth endpoints. +// +// A wildcard is never used: these requests carry credentials, and "*" with +// credentials would let any site drive a user's OAuth flow. +func oauthAllowedOrigins(app *application) []string { + origins := []string{app.cfg.OAuth.PublicURL} + + if app.cfg.IsDevEnv { + // Local frontends: the SvelteKit dev server, the PDS, and the + // AppView itself, on both localhost spellings. + origins = append(origins, + "http://localhost:3000", + "http://localhost:3001", + "http://localhost:5173", + "http://127.0.0.1:8080", + "http://127.0.0.1:3000", + "http://127.0.0.1:3001", + "http://127.0.0.1:5173", + ) + slog.Warn("dev mode: OAuth CORS additionally allows localhost origins") + } + + slog.Info("OAuth CORS configured", "allowed_origins", origins) + return origins +} + +// registerWebRoutes mounts the landing page, the account deletion flow, the +// mobile captcha page, static assets, and the mobile deep-linking manifests. +func registerWebRoutes(r chi.Router, app *application) { + routes.RegisterWellKnownRoutes(r) + routes.RegisterWebRoutes(r, app.oauthClient, app.userService, app.cfg.Signup.TurnstileSiteKey) + slog.Info("web and well-known endpoints registered") +} + +// registerHealthRoutes mounts liveness and indexing-health endpoints. +// +// /health and /xrpc/_health stay pure liveness checks — they are the container +// healthcheck target, so a Jetstream outage must not restart-loop the AppView. +// Indexing health is reported separately by /health/consumers, which monitoring +// can alert on without the orchestrator acting on it. +func registerHealthRoutes(r chi.Router, app *application, consumers *consumerSet) { + r.Get("/health", livenessHandler) + r.Get("/xrpc/_health", livenessHandler) + r.Get("/health/consumers", consumerHealthHandler(consumers.connectors, app.jetstreamState)) +} diff --git a/cmd/server/wiring.go b/cmd/server/wiring.go new file mode 100644 index 0000000..f5d8e19 --- /dev/null +++ b/cmd/server/wiring.go @@ -0,0 +1,516 @@ +package main + +import ( + "Coves/internal/api/middleware" + "Coves/internal/atproto/identity" + "Coves/internal/atproto/jetstream" + "Coves/internal/atproto/oauth" + "Coves/internal/config" + "Coves/internal/core/adminreports" + "Coves/internal/core/aggregators" + "Coves/internal/core/blobs" + "Coves/internal/core/blueskypost" + "Coves/internal/core/comments" + "Coves/internal/core/communities" + "Coves/internal/core/communityFeeds" + "Coves/internal/core/communitysuggestions" + "Coves/internal/core/discover" + "Coves/internal/core/imageproxy" + "Coves/internal/core/posts" + "Coves/internal/core/timeline" + "Coves/internal/core/unfurl" + "Coves/internal/core/userblocks" + "Coves/internal/core/users" + "Coves/internal/core/votes" + "context" + "database/sql" + "fmt" + "log/slog" + "net/http" + "sync" + "time" + + imageproxyhandlers "Coves/internal/api/handlers/imageproxy" + postgresRepo "Coves/internal/db/postgres" + + indigoauth "github.com/bluesky-social/indigo/atproto/auth" + indigoidentity "github.com/bluesky-social/indigo/atproto/identity" +) + +const ( + // identityHTTPTimeout bounds DID document fetches made while validating + // aggregator service JWTs. The middleware passes the request context to + // the validator, so cancellation is already honoured; this is the safety + // net for a PLC directory that accepts connections but never answers. + identityHTTPTimeout = 10 * time.Second + + // profileBackfillTimeout bounds the best-effort fetch of a user's + // social.coves.actor.profile from their PDS during indexing. + profileBackfillTimeout = 10 * time.Second + + // unfurlTimeout bounds fetching and parsing a link preview target. + unfurlTimeout = 10 * time.Second + + // unfurlCacheTTL is how long a link preview is reused before refetching. + unfurlCacheTTL = 24 * time.Hour + + // blueskyFetchTimeout bounds fetching a quoted Bluesky post. + blueskyFetchTimeout = 10 * time.Second + + // blueskyCacheTTL is deliberately shorter than unfurlCacheTTL: a quoted + // post's engagement counts go stale faster than a link's title does. + blueskyCacheTTL = time.Hour + + // voteCacheTTL is how long a user's votes read from their PDS are reused. + // It papers over PDS eventual consistency between casting a vote and the + // firehose delivering it back; the cache is also written through on + // create and delete. + voteCacheTTL = 10 * time.Minute + + // unfurlUserAgent identifies Coves to the sites it fetches previews from. + unfurlUserAgent = "CovesBot/1.0 (+https://coves.social)" +) + +// application holds every constructed dependency, wired once at startup and +// then read-only. It exists so the wiring can be split across focused +// functions without threading a dozen parameters through each one. +type application struct { + cfg *config.Config + db *sql.DB + + // Identity and authentication + identityResolver identity.Resolver + oauthClient *oauth.OAuthClient + oauthStore *oauth.MobileAwareStoreWrapper + oauthHandler *oauth.OAuthHandler + authMiddleware *middleware.OAuthAuthMiddleware + dualAuth *middleware.DualAuthMiddleware + + // Repositories reused outside their own service (Jetstream consumers, + // route options). + userRepo users.UserRepository + communityRepo communities.Repository + postRepo posts.Repository + voteRepo votes.Repository + commentRepo comments.Repository + userBlockRepo userblocks.Repository + aggregatorRepo aggregators.Repository + + // Domain services + userService users.UserService + communityService communities.Service + postService posts.Service + voteService votes.Service + commentService comments.Service + userBlockService userblocks.Service + adminReportService adminreports.Service + communitySuggestionService communitysuggestions.Service + feedService communityFeeds.Service + timelineService timeline.Service + discoverService discover.Service + aggregatorService aggregators.Service + apiKeyService *aggregators.APIKeyService + blueskyService blueskypost.Service + + // Jetstream indexing infrastructure + jetstreamState *jetstream.PostgresStateStore + revGate *jetstream.RevGate + bridgeTrust *jetstream.BridgeTrust + + // imageProxyHandler is nil when the image proxy is disabled. + imageProxyHandler *imageproxyhandlers.Handler + // stopImageProxyCleanup halts the disk cache eviction job. Never nil. + stopImageProxyCleanup context.CancelFunc + // closeOnce guards Close, which is reached from both serve and run's + // deferred cleanup on every shutdown. + closeOnce sync.Once +} + +// buildApplication constructs every repository, service, and middleware the +// server needs, in dependency order. +// +// Ordering here is load-bearing in a few places, each noted at the call site. +// +// On failure it cleans up whatever it already started and returns (nil, err), +// the conventional Go contract. Returning a usable value alongside an error +// and asking the caller to Close it would invert that convention, and would +// become a nil dereference during an already-failing boot the first time +// anyone added a plain `return nil, err` below. +func buildApplication(ctx context.Context, cfg *config.Config, db *sql.DB) (app *application, err error) { + app = &application{ + cfg: cfg, + db: db, + stopImageProxyCleanup: func() {}, + } + + defer func() { + if err != nil { + app.Close() + app = nil + } + }() + + app.buildIdentity() + if err = app.buildAuth(); err != nil { + return nil, err + } + app.buildRepositories() + if err = app.buildServices(ctx); err != nil { + return nil, err + } + if err = app.buildImageProxy(); err != nil { + return nil, err + } + app.buildJetstreamInfrastructure() + return app, nil +} + +// Close releases resources owned by the application that are not tied to the +// process lifetime. It is safe to call more than once and from more than one +// goroutine — both serve and run's deferred cleanup reach it. +func (a *application) Close() { + a.closeOnce.Do(func() { + a.stopImageProxyCleanup() + }) +} + +func (a *application) buildIdentity() { + identityConfig := identity.DefaultConfig() + identityConfig.PLCURL = a.cfg.Identity.ResolverPLCURL + if a.cfg.Identity.CacheTTL > 0 { + identityConfig.CacheTTL = a.cfg.Identity.CacheTTL + } + a.identityResolver = identity.NewResolver(a.db, identityConfig) + + if a.cfg.IsDevEnv { + slog.Warn("dev mode: identity resolver is using a local PLC directory", + "plc_url", identityConfig.PLCURL) + } else { + slog.Info("identity resolver initialized", "plc_url", identityConfig.PLCURL) + } +} + +func (a *application) buildAuth() error { + // The wrapper intercepts SaveAuthRequestInfo to capture mobile CSRF state + // from the request context, so it must be what everything else holds. + baseOAuthStore := oauth.NewPostgresOAuthStore(a.db, 0) // 0 = default 7-day TTL + a.oauthStore = oauth.NewMobileAwareStoreWrapper(baseOAuthStore) + + oauthConfig := &oauth.OAuthConfig{ + PublicURL: a.cfg.OAuth.PublicURL, + SealSecret: a.cfg.OAuth.SealSecret, + Scopes: oauthScopes(), + DevMode: a.cfg.IsDevEnv, + // Private IPs are only resolvable in dev, where the PDS and PLC run + // on localhost. In production this must stay off: it is what stops + // OAuth from being pointed at an internal address. + AllowPrivateIPs: a.cfg.IsDevEnv, + PLCURL: a.cfg.Identity.PLCURL, + PDSURL: a.cfg.PDS.URL, + // Setting both upgrades this to a confidential client, lifting the + // 14-day session cap the authorization server imposes on public + // clients. Our own defaults then apply — see oauth.NewOAuthClient. + ClientPrivateKeyMultibase: a.cfg.OAuth.ClientPrivateKeyMultibase, + ClientKeyID: a.cfg.OAuth.ClientKeyID, + } + + client, err := oauth.NewOAuthClient(oauthConfig, a.oauthStore) + if err != nil { + return fmt.Errorf("initializing OAuth client: %w", err) + } + a.oauthClient = client + + a.authMiddleware = middleware.NewOAuthAuthMiddleware(a.oauthClient, a.oauthStore) + slog.Info("OAuth auth middleware initialized (sealed session tokens)") + return nil +} + +// oauthScopes lists the atProto OAuth scopes Coves requests. Each collection +// is scoped to the exact actions the AppView performs on the user's behalf. +func oauthScopes() []string { + return []string{ + "atproto", + "blob:*/*", // avatar and image uploads + "repo:social.coves.community.post?action=create&action=update&action=delete", + "repo:social.coves.community.comment?action=create&action=update&action=delete", + "repo:social.coves.community.profile?action=create&action=update&action=delete", + "repo:social.coves.community.subscription?action=create&action=update&action=delete", + "repo:social.coves.actor.profile?action=create&action=update&action=delete", + "repo:social.coves.feed.vote?action=create&action=delete", + "repo:social.coves.actor.block?action=create&action=delete", + } +} + +func (a *application) buildRepositories() { + a.userRepo = postgresRepo.NewUserRepository(a.db) + a.communityRepo = postgresRepo.NewCommunityRepository(a.db) + a.postRepo = postgresRepo.NewPostRepository(a.db) + a.voteRepo = postgresRepo.NewVoteRepository(a.db) + a.commentRepo = postgresRepo.NewCommentRepository(a.db) + a.userBlockRepo = postgresRepo.NewUserBlockRepository(a.db) + a.aggregatorRepo = postgresRepo.NewAggregatorRepository(a.db) +} + +func (a *application) buildServices(ctx context.Context) error { + var turnstileVerifier users.TurnstileVerifier + if a.cfg.Signup.TurnstileSecretKey != "" { + turnstileVerifier = users.NewCloudflareTurnstile(a.cfg.Signup.TurnstileSecretKey) + } + + // Profile backfill covers users indexed without profile data — typically + // because their profile firehose event was missed — by fetching + // social.coves.actor.profile from their PDS asynchronously during + // IndexUser. + a.userService = users.NewUserService( + a.userRepo, + a.identityResolver, + a.cfg.PDS.URL, + turnstileVerifier, + a.cfg.PDS.AdminPassword, + users.WithProfileBackfill(&http.Client{Timeout: profileBackfillTimeout}), + ) + + // The OAuth handler indexes users into the AppView after login, so it + // must be built after userService. + a.oauthHandler = oauth.NewOAuthHandler(a.oauthClient, a.oauthStore, + oauth.WithUserIndexer(a.userService)) + + blobService := blobs.NewBlobService(a.cfg.PDS.URL) + + // V2.0: the PDS generates and manages community DIDs and keys entirely; + // Coves performs no cryptography of its own here. + provisioner := communities.NewPDSAccountProvisioner(a.cfg.Instance.Domain, a.cfg.PDS.URL) + a.communityService = communities.NewCommunityService( + a.communityRepo, + a.cfg.PDS.URL, + a.cfg.Instance.DID, + a.cfg.Instance.Domain, + provisioner, + a.oauthClient, + blobService, + ) + a.authenticateInstanceWithPDS(ctx) + + a.aggregatorService = aggregators.NewAggregatorService(a.aggregatorRepo, a.communityService) + a.apiKeyService = aggregators.NewAPIKeyService(a.aggregatorRepo, a.oauthClient.ClientApp) + + a.buildDualAuth() + + unfurlService := unfurl.NewService( + unfurl.NewRepository(a.db), + unfurl.WithTimeout(unfurlTimeout), + unfurl.WithUserAgent(unfurlUserAgent), + unfurl.WithCacheTTL(unfurlCacheTTL), + ) + + // Quoted Bluesky posts reference real handles on the production atProto + // network, which the dev/test PLC cannot resolve. This resolver is + // therefore always pointed at plc.directory — and is safe to use in dev + // because identity.Resolver only ever issues HTTP GETs. + productionPLCConfig := identity.DefaultConfig() + productionPLCConfig.PLCURL = "https://plc.directory" + productionPLCResolver := identity.NewResolver(a.db, productionPLCConfig) + + a.blueskyService = blueskypost.NewService( + blueskypost.NewRepository(a.db), + productionPLCResolver, + blueskypost.WithTimeout(blueskyFetchTimeout), + blueskypost.WithCacheTTL(blueskyCacheTTL), + ) + + // userBlockRepo backs viewer block enforcement on GetPosts, keeping + // permalink and cold-load reads consistent with feed/timeline filtering. + a.postService = posts.NewPostService( + a.postRepo, a.communityService, a.aggregatorService, blobService, + unfurlService, a.blueskyService, a.cfg.PDS.URL, + posts.WithBlockChecker(a.userBlockRepo), + ) + + // Subject existence is deliberately not validated: the vote is written to + // the user's own PDS regardless, and the Jetstream consumer only updates + // counts for subjects that still exist. Checking here would trade a + // harmless orphan vote for a race against eventual consistency. + voteCache := votes.NewVoteCache(voteCacheTTL, nil) + a.voteService = votes.NewService(a.voteRepo, a.oauthClient, a.oauthStore, voteCache, nil) + + a.commentService = comments.NewCommentService( + a.commentRepo, a.userRepo, a.postRepo, a.communityRepo, + a.oauthClient, a.oauthStore, nil, + ) + a.userBlockService = userblocks.NewService(a.userBlockRepo, nil, a.oauthClient, a.oauthStore, nil) + a.adminReportService = adminreports.NewService(postgresRepo.NewAdminReportRepository(a.db)) + a.communitySuggestionService = communitysuggestions.NewService( + postgresRepo.NewCommunitySuggestionRepository(a.db)) + + a.feedService = communityFeeds.NewCommunityFeedService( + postgresRepo.NewCommunityFeedRepository(a.db, a.cfg.CursorSecret), a.communityService) + a.timelineService = timeline.NewTimelineService( + postgresRepo.NewTimelineRepository(a.db, a.cfg.CursorSecret)) + a.discoverService = discover.NewDiscoverService( + postgresRepo.NewDiscoverRepository(a.db, a.cfg.CursorSecret)) + + slog.Info("domain services initialized") + return nil +} + +// buildDualAuth wires the middleware that accepts all three credential types: +// sealed OAuth session tokens (users), PDS-signed service JWTs (aggregators), +// and API keys (aggregator bots). +func (a *application) buildDualAuth() { + identityDir := &indigoidentity.BaseDirectory{ + PLCURL: a.cfg.Identity.PLCURL, + HTTPClient: http.Client{Timeout: identityHTTPTimeout}, + } + serviceValidator := &indigoauth.ServiceAuthValidator{ + // The instance DID is the audience aggregator JWTs must be issued for. + Audience: a.cfg.Instance.DID, + Dir: identityDir, + TimestampLeeway: 30 * time.Second, + } + + a.dualAuth = middleware.NewDualAuthMiddleware( + a.oauthClient, // SessionUnsealer for OAuth + a.oauthStore, // ClientAuthStore for OAuth sessions + serviceValidator, // service JWT validation + a.aggregatorRepo, // AggregatorChecker + ).WithAPIKeyValidator(middleware.NewAPIKeyValidatorAdapter(a.apiKeyService)) + + slog.Info("dual auth middleware initialized (OAuth + service JWT + API keys)", + "service_jwt_audience", a.cfg.Instance.DID) +} + +// authenticateInstanceWithPDS logs the instance into its own PDS account and +// hands the resulting access token to the community service. +// +// CAVEAT, and the reason none of the messages below claim a capability: the +// token this obtains is currently inert. communityService stores it in +// pdsAccessToken, and nothing reads that field — community creation actually +// authenticates with the per-community PDS accounts issued by +// PDSAccountProvisioner, each carrying its own token and refresh path. This +// call is therefore a credential check, not a prerequisite for anything. +// +// It is kept rather than deleted because the credentials are still worth +// validating at boot, and instance-owned writes are a plausible future need. +// If that need does not materialise, delete this along with authenticateWithPDS, +// SetPDSAccessToken, and the pdsAccessToken field. What must not happen is the +// previous state: log lines confidently reporting that community creation is +// disabled, or will fail, or is now enabled — none of which were true, and each +// of which sent an operator chasing a phantom. +// +// Failure is non-fatal for the same reason: nothing downstream depends on it. +func (a *application) authenticateInstanceWithPDS(ctx context.Context) { + if !a.cfg.PDS.HasInstanceCredentials() { + slog.Info("PDS_INSTANCE_HANDLE / PDS_INSTANCE_PASSWORD not set; " + + "skipping the instance PDS credential check") + return + } + + accessToken, err := authenticateWithPDS(ctx, a.cfg.PDS.URL, + a.cfg.PDS.InstanceHandle, a.cfg.PDS.InstancePassword) + if err != nil { + slog.Warn("instance PDS credential check failed; no current feature depends on it", + "instance_did", a.cfg.Instance.DID, + "pds_url", a.cfg.PDS.URL, + "error", err, + ) + return + } + + setter, ok := a.communityService.(interface{ SetPDSAccessToken(string) }) + if !ok { + slog.Warn("community service does not accept a PDS access token") + return + } + setter.SetPDSAccessToken(accessToken) + slog.Info("instance authenticated with PDS", "instance_did", a.cfg.Instance.DID) +} + +// buildImageProxy sets up the optional resizing image proxy and publishes the +// URL-generation settings the communities package uses to render avatars. +// +// The URL config is published on every success path — including when the proxy +// is disabled — because communities needs to know whether to emit proxy URLs or +// direct blob URLs. +func (a *application) buildImageProxy() error { + cfg := imageproxy.ConfigFromEnv() + + // Published on every path, including the disabled one: communities needs + // to know whether to render proxy URLs or direct blob URLs. Set explicitly + // at each exit rather than via defer — defer is for cleanup, and using it + // for control flow hides that this is the function's main effect when the + // proxy is off. + publishURLConfig := func() { + communities.SetImageProxyConfig(blobs.ImageURLConfig{ + ProxyEnabled: cfg.Enabled, + ProxyBaseURL: cfg.BaseURL, + CDNURL: cfg.CDNURL, + }) + } + + if !cfg.Enabled { + publishURLConfig() + slog.Info("image proxy disabled; blob URLs will be served directly") + return nil + } + + if err := cfg.Validate(); err != nil { + return fmt.Errorf("image proxy configuration: %w", err) + } + + cache, err := imageproxy.NewDiskCache(cfg.CachePath, cfg.CacheMaxGB, cfg.CacheTTLDays) + if err != nil { + return fmt.Errorf("creating image proxy cache: %w", err) + } + a.stopImageProxyCleanup = cache.StartCleanupJob(cfg.CleanupInterval) + + service, err := imageproxy.NewService( + cache, + imageproxy.NewProcessor(), + imageproxy.NewPDSFetcher(cfg.FetchTimeout, cfg.MaxSourceSizeMB), + cfg, + ) + if err != nil { + return fmt.Errorf("creating image proxy service: %w", err) + } + + a.imageProxyHandler = imageproxyhandlers.NewHandler(service, a.identityResolver) + publishURLConfig() + + slog.Info("image proxy enabled", + "base_url", cfg.BaseURL, + "cdn_url", cfg.CDNURL, + "cache_path", cfg.CachePath, + "cache_max_gb", cfg.CacheMaxGB, + "cache_ttl_days", cfg.CacheTTLDays, + "cleanup_interval", cfg.CleanupInterval, + "fetch_timeout", cfg.FetchTimeout, + "max_source_size_mb", cfg.MaxSourceSizeMB, + ) + return nil +} + +func (a *application) buildJetstreamInfrastructure() { + // Cursors let each consumer resume from its last processed event after a + // restart instead of silently losing the gap; the dead letter queue + // captures events that fail every in-line retry so the redriver can + // replay them once the underlying failure clears. + a.jetstreamState = jetstream.NewPostgresStateStore(a.db) + + // The rev gate is the per-record ordering guard that makes it safe to run + // every consumer against multiple Jetstream feeds carrying the same repos + // (see rev_gate.go and migration 033). + a.revGate = jetstream.NewRevGate(a.db) + + // Provenance gate for bridge-asserted vote aggregates. Only repos hosted + // on a trusted bridge PDS may inflate their displayed counts via + // bridgedStats; every native repo is default-denied so it cannot + // self-assert them. Empty means bridgedStats are ignored everywhere, + // which is the right default for a deployment with no bridge. + a.bridgeTrust = jetstream.NewBridgeTrust(a.cfg.Instance.TrustedBridgePDSHosts) + if len(a.cfg.Instance.TrustedBridgePDSHosts) > 0 { + slog.Info("bridgedStats provenance configured", + "trusted_bridge_hosts", len(a.cfg.Instance.TrustedBridgePDSHosts)) + } else { + slog.Info("no trusted bridge PDS hosts configured; bridgedStats will be ignored") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..68cb0a7 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,650 @@ +package config + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net/url" + "strings" + "time" +) + +// devCursorSecret is the placeholder HMAC key used for pagination cursors when +// CURSOR_SECRET is unset. It is only ever accepted in dev: in production a +// known cursor secret lets anyone forge a signed cursor, so Validate rejects it. +const devCursorSecret = "dev-cursor-secret-change-in-production" + +const ( + // sealSecretBytes is the decoded length oauth.NewOAuthClient requires of + // OAUTH_SEAL_SECRET. + sealSecretBytes = 32 + + // minSecretLength is the shortest value accepted for a production secret + // that is used directly as key material rather than decoded. + minSecretLength = 16 +) + +// placeholderPrefix marks the documented "fill this in" values in +// .env.prod.example. They are published in the repository, so a production +// deployment still carrying one has no secret at all. +const placeholderPrefix = "CHANGE_ME" + +// isPlaceholder reports whether value is one of the documented placeholders. +func isPlaceholder(value string) bool { + return strings.HasPrefix(value, placeholderPrefix) +} + +// requirePublicHost rejects a URL that is empty or points at the loopback +// interface, which in production means the dev default was never replaced. +func requirePublicHost(name, rawURL string) error { + if rawURL == "" { + return fmt.Errorf("%s is required in production", name) + } + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("%s is not a valid URL: %w", name, err) + } + host := parsed.Hostname() + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + return fmt.Errorf("%s must not point at localhost in production (got %q); "+ + "the loopback default is dev-only and leaves this unreachable to clients", + name, rawURL) + } + return nil +} + +// legacyJetstreamVars are single-feed environment variables replaced by +// JETSTREAM_FEEDS. They are rejected rather than ignored — silently dropping a +// configured firehose URL would leave the AppView indexing nothing while +// looking healthy. +var legacyJetstreamVars = []string{ + "JETSTREAM_URL", + "COMMUNITY_JETSTREAM_URL", + "POST_JETSTREAM_URL", + "AGGREGATOR_JETSTREAM_URL", + "VOTE_JETSTREAM_URL", + "COMMENT_JETSTREAM_URL", +} + +// Config is the fully resolved server configuration. Defaults are applied +// during Load; Validate then enforces the constraints that differ between dev +// and production. +type Config struct { + // IsDevEnv relaxes several production requirements (private-IP OAuth + // resolution, generated secrets, localhost defaults). It must never be + // true in a real deployment. + IsDevEnv bool + + Database DatabaseConfig + Server ServerConfig + Identity IdentityConfig + OAuth OAuthConfig + Instance InstanceConfig + PDS PDSConfig + Jetstream JetstreamConfig + Signup SignupConfig + + // CursorSecret is the HMAC key that signs pagination cursors, preventing + // clients from forging or tampering with them. + CursorSecret string +} + +// DatabaseConfig holds the AppView PostgreSQL connection and pool settings. +// +// The pool bounds matter: database/sql defaults to an unlimited number of open +// connections, so a traffic spike can open connections until PostgreSQL's +// max_connections (100 by default) is exhausted — which locks out every other +// client, including psql and the maintenance commands under cmd/. +type DatabaseConfig struct { + // URL is the libpq connection string for the AppView database. + URL string + + // MaxOpenConns caps total connections (in use + idle). Kept well below + // PostgreSQL's default max_connections of 100 so operators and the + // backfill/reindex tools can still connect during a spike. + MaxOpenConns int + + // MaxIdleConns caps connections retained for reuse. The database/sql + // default is 2, which forces a fresh connection and PostgreSQL startup + // handshake for nearly every query under concurrency; matching + // MaxOpenConns avoids that churn. + MaxIdleConns int + + // ConnMaxLifetime retires connections after this age, so a rolling + // PostgreSQL restart or failover does not strand the pool on dead + // connections. + ConnMaxLifetime time.Duration + + // ConnMaxIdleTime releases connections idle for this long, returning + // server-side memory after a spike subsides. + ConnMaxIdleTime time.Duration + + // StatementTimeout bounds how long a single query may run server-side. + // This is enforced by PostgreSQL rather than by the client, so a runaway + // query is actually cancelled instead of merely being abandoned while it + // continues to hold a connection and a backend process. Zero disables it. + StatementTimeout time.Duration +} + +// ServerConfig holds the HTTP listener settings. +// +// The timeouts are required, not optional hardening: with all four at zero +// (the net/http default) a single client that opens a connection and never +// completes its request headers pins a goroutine and a file descriptor +// indefinitely. Enough of them exhaust the process's file-descriptor limit — +// the classic slowloris attack. Validate enforces that none is zero. +type ServerConfig struct { + // Port is the TCP port to listen on. + Port string + + // ReadHeaderTimeout bounds the time allowed to send request headers. + // This is the specific defence against slowloris. + ReadHeaderTimeout time.Duration + + // ReadTimeout bounds reading the entire request, headers plus body. + ReadTimeout time.Duration + + // WriteTimeout bounds the time from end-of-headers to end-of-response. + // It must comfortably exceed the slowest legitimate handler — the image + // proxy, which may spend IMAGE_PROXY_FETCH_TIMEOUT_SECONDS (30s by + // default) fetching from a remote PDS before it encodes anything. + WriteTimeout time.Duration + + // IdleTimeout bounds how long an idle keep-alive connection is kept open. + IdleTimeout time.Duration + + // ShutdownTimeout bounds graceful shutdown: draining in-flight requests + // and flushing Jetstream consumer cursors. + ShutdownTimeout time.Duration +} + +// IdentityConfig holds atProto identity resolution settings. +type IdentityConfig struct { + // PLCURL is the PLC directory used to resolve DIDs. + PLCURL string + + // ResolverPLCURL is the PLC directory used by the identity resolver. In + // dev this is forced to the local PLC so end-to-end tests never touch + // plc.directory; in production IDENTITY_PLC_URL may point reads at a + // separate mirror. + ResolverPLCURL string + + // CacheTTL overrides the resolver's default cache lifetime. Zero means + // use the resolver default. + CacheTTL time.Duration +} + +// OAuthConfig holds the atProto OAuth client settings. +type OAuthConfig struct { + // PublicURL is this AppView's externally reachable base URL. It appears + // in the OAuth client metadata and the redirect URI, so it must match + // what the authorization server sees. + PublicURL string + + // SealSecret encrypts the sealed session tokens handed to clients. It + // must be stable across restarts: rotating it invalidates every live + // session, signing out every mobile and web user. + SealSecret string + + // SealSecretGenerated reports that SealSecret was randomly generated + // because OAUTH_SEAL_SECRET was unset. Dev-only, and worth warning about + // loudly — it means every restart signs all users out. + SealSecretGenerated bool + + // ClientPrivateKeyMultibase and ClientKeyID upgrade this to a + // confidential OAuth client when both are set, which raises the session + // lifetime the authorization server will grant. + ClientPrivateKeyMultibase string + ClientKeyID string +} + +// InstanceConfig holds this Coves instance's atProto identity. +type InstanceConfig struct { + // DID identifies this instance and is the audience for aggregator + // service JWTs. + DID string + + // Domain suffixes community handles. For did:web instance DIDs it is + // derived from the DID itself rather than read from the environment: + // allowing an arbitrary domain would let an instance mint handles like + // !leagueoflegends@riotgames.com and impersonate another operator. + Domain string + + // AllowedCommunityCreators restricts community creation to these DIDs. + // Nil means any authenticated user may create a community. + AllowedCommunityCreators []string + + // TrustedBridgePDSHosts may assert bridged vote aggregates + // (bridgedStats) for the repos they host. Every other repo is + // default-denied so it cannot inflate its own vote counts. Nil means + // bridgedStats are ignored everywhere. + TrustedBridgePDSHosts []string + + // SkipDIDWebVerification disables did:web domain verification in the + // community consumer. Dev-only: it is what stops a community record from + // claiming to be hosted by a domain it does not control. + SkipDIDWebVerification bool +} + +// PDSConfig holds the settings for this instance's own PDS account, used to +// write the community records the instance owns. +type PDSConfig struct { + // URL is the default PDS for this instance. + URL string + + // InstanceHandle and InstancePassword authenticate the instance's PDS + // account. When either is empty, community write-forward is disabled. + InstanceHandle string + InstancePassword string + + // AdminPassword mints single-use PDS invite codes after a successful + // captcha. Empty disables the signup-token endpoint. + AdminPassword string +} + +// HasInstanceCredentials reports whether the instance can authenticate with +// its PDS to write community records. +func (p PDSConfig) HasInstanceCredentials() bool { + return p.InstanceHandle != "" && p.InstancePassword != "" +} + +// JetstreamConfig holds the firehose feed topology. +type JetstreamConfig struct { + // FeedsSpec is the raw semicolon-separated = list. + // Parsing into feeds lives in the jetstream package, which owns the + // primary-feed and cursor-naming semantics. + FeedsSpec string +} + +// SignupConfig holds the bot-protected signup settings. +// +// Signup stays gated by the PDS's own PDS_INVITE_REQUIRED, so missing config +// here means signup is closed, never that it is open and unprotected. +type SignupConfig struct { + // TurnstileSiteKey is the public Cloudflare key embedded in the mobile + // WebView captcha page. Empty makes that page return 503. + TurnstileSiteKey string + + // TurnstileSecretKey verifies captcha tokens server-side. + TurnstileSecretKey string +} + +// TokenEndpointEnabled reports whether the signup-token endpoint can operate. +// It needs both the captcha secret and (from PDSConfig) an admin password to +// mint invite codes, so the caller passes the latter in. +func (s SignupConfig) TokenEndpointEnabled(pdsAdminPassword string) bool { + return s.TurnstileSecretKey != "" && pdsAdminPassword != "" +} + +// Load reads the full server configuration from the environment, applies +// defaults, and validates the result. A returned error is fatal: the process +// is misconfigured and should not start. +func Load() (*Config, error) { + isDevEnv, err := boolVar("IS_DEV_ENV", false) + if err != nil { + return nil, err + } + + cfg := &Config{IsDevEnv: isDevEnv} + + if err := cfg.loadDatabase(); err != nil { + return nil, err + } + if err := cfg.loadServer(); err != nil { + return nil, err + } + if err := cfg.loadIdentity(); err != nil { + return nil, err + } + if err := cfg.loadOAuth(); err != nil { + return nil, err + } + if err := cfg.loadInstance(); err != nil { + return nil, err + } + if err := cfg.loadJetstream(); err != nil { + return nil, err + } + + cfg.PDS = PDSConfig{ + URL: stringVar("PDS_URL", "http://localhost:3001"), + InstanceHandle: lookup("PDS_INSTANCE_HANDLE"), + InstancePassword: lookup("PDS_INSTANCE_PASSWORD"), + AdminPassword: lookup("PDS_ADMIN_PASSWORD"), + } + + cfg.Signup = SignupConfig{ + TurnstileSiteKey: lookup("TURNSTILE_SITE_KEY"), + TurnstileSecretKey: lookup("TURNSTILE_SECRET_KEY"), + } + + cfg.CursorSecret = stringVar("CURSOR_SECRET", devCursorSecret) + + if err := cfg.Validate(); err != nil { + return nil, err + } + return cfg, nil +} + +func (c *Config) loadDatabase() error { + maxOpen, err := intVar("DB_MAX_OPEN_CONNS", 25) + if err != nil { + return err + } + // Default idle to open so a burst of concurrent queries reuses warm + // connections instead of reconnecting; database/sql's default of 2 makes + // the pool thrash under exactly the load it exists to absorb. + maxIdle, err := intVar("DB_MAX_IDLE_CONNS", maxOpen) + if err != nil { + return err + } + connMaxLifetime, err := durationVar("DB_CONN_MAX_LIFETIME", 30*time.Minute) + if err != nil { + return err + } + connMaxIdleTime, err := durationVar("DB_CONN_MAX_IDLE_TIME", 5*time.Minute) + if err != nil { + return err + } + statementTimeout, err := durationVar("DB_STATEMENT_TIMEOUT", 30*time.Second) + if err != nil { + return err + } + + c.Database = DatabaseConfig{ + URL: stringVar("DATABASE_URL", + "postgres://dev_user:dev_password@localhost:5435/coves_dev?sslmode=disable"), + MaxOpenConns: maxOpen, + MaxIdleConns: maxIdle, + ConnMaxLifetime: connMaxLifetime, + ConnMaxIdleTime: connMaxIdleTime, + StatementTimeout: statementTimeout, + } + return nil +} + +func (c *Config) loadServer() error { + readHeaderTimeout, err := durationVar("HTTP_READ_HEADER_TIMEOUT", 10*time.Second) + if err != nil { + return err + } + readTimeout, err := durationVar("HTTP_READ_TIMEOUT", 30*time.Second) + if err != nil { + return err + } + // Generous by design: the image proxy may spend its full fetch timeout + // (30s default) pulling a source image from a remote PDS before writing + // a single byte. The goal is a bound, not an aggressive one. + writeTimeout, err := durationVar("HTTP_WRITE_TIMEOUT", 120*time.Second) + if err != nil { + return err + } + idleTimeout, err := durationVar("HTTP_IDLE_TIMEOUT", 120*time.Second) + if err != nil { + return err + } + shutdownTimeout, err := durationVar("HTTP_SHUTDOWN_TIMEOUT", 30*time.Second) + if err != nil { + return err + } + + // PORT is what docker-compose sets; APPVIEW_PORT is the legacy name. + port := stringVar("PORT", stringVar("APPVIEW_PORT", "8080")) + + c.Server = ServerConfig{ + Port: port, + ReadHeaderTimeout: readHeaderTimeout, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + ShutdownTimeout: shutdownTimeout, + } + return nil +} + +func (c *Config) loadIdentity() error { + cacheTTL, err := durationVar("IDENTITY_CACHE_TTL", 0) + if err != nil { + return err + } + + plcURL := stringVar("PLC_DIRECTORY_URL", "https://plc.directory") + + // In dev, identity resolution must use the same local PLC that + // registration writes to, or end-to-end tests resolve DIDs that do not + // exist yet. In production a separate read mirror may be configured. + resolverPLCURL := plcURL + if !c.IsDevEnv { + resolverPLCURL = stringVar("IDENTITY_PLC_URL", plcURL) + } + + c.Identity = IdentityConfig{ + PLCURL: plcURL, + ResolverPLCURL: resolverPLCURL, + CacheTTL: cacheTTL, + } + return nil +} + +func (c *Config) loadOAuth() error { + sealSecret := lookup("OAUTH_SEAL_SECRET") + generated := false + if sealSecret == "" && c.IsDevEnv { + // Dev convenience only. Validate rejects an empty secret in + // production, where a per-boot random key would sign every user out + // on each deploy. + randomBytes := make([]byte, 32) + if _, err := rand.Read(randomBytes); err != nil { + return fmt.Errorf("generating dev OAuth seal secret: %w", err) + } + sealSecret = base64.StdEncoding.EncodeToString(randomBytes) + generated = true + } + + c.OAuth = OAuthConfig{ + PublicURL: stringVar("APPVIEW_PUBLIC_URL", "http://localhost:8080"), + SealSecret: sealSecret, + SealSecretGenerated: generated, + ClientPrivateKeyMultibase: lookup("OAUTH_CLIENT_PRIVATE_KEY"), + ClientKeyID: lookup("OAUTH_CLIENT_KEY_ID"), + } + return nil +} + +func (c *Config) loadInstance() error { + skipDIDWeb, err := boolVar("SKIP_DID_WEB_VERIFICATION", false) + if err != nil { + return err + } + + did := stringVar("INSTANCE_DID", "did:web:coves.social") + + // For did:web the DID *is* the domain claim, so deriving the domain from + // it keeps the two from drifting apart. Only non-web DIDs (did:plc) need + // INSTANCE_DOMAIN, and then it is required. + var domain string + if suffix, ok := strings.CutPrefix(did, "did:web:"); ok { + domain = suffix + } else { + domain = lookup("INSTANCE_DOMAIN") + } + + c.Instance = InstanceConfig{ + DID: did, + Domain: domain, + AllowedCommunityCreators: csvVar("COMMUNITY_CREATORS"), + TrustedBridgePDSHosts: csvVar("TRUSTED_BRIDGE_PDS_HOSTS"), + SkipDIDWebVerification: skipDIDWeb, + } + return nil +} + +func (c *Config) loadJetstream() error { + for _, legacy := range legacyJetstreamVars { + if lookup(legacy) != "" { + return fmt.Errorf("%s is no longer supported: configure feeds via JETSTREAM_FEEDS "+ + "(e.g. \"bsky=wss://jetstream2.us-east.bsky.network;self=ws://tidepool-prod-jetstream:8080\") "+ + "and remove the legacy variable", legacy) + } + } + + feedsSpec := lookup("JETSTREAM_FEEDS") + if feedsSpec == "" && c.IsDevEnv { + // Dev default: the local dev-stack Jetstream only. Production must + // always be explicit — see Validate. + feedsSpec = "self=ws://localhost:6008" + } + + c.Jetstream = JetstreamConfig{FeedsSpec: feedsSpec} + return nil +} + +// Validate enforces the constraints that Load's defaults cannot express, +// notably the ones that differ between dev and production. It returns every +// problem at once so a misconfigured deployment can be fixed in a single pass +// instead of one restart per mistake. +func (c *Config) Validate() error { + var problems []string + + if c.Database.URL == "" { + problems = append(problems, "DATABASE_URL is required") + } + if c.Database.MaxOpenConns == 0 { + problems = append(problems, "DB_MAX_OPEN_CONNS must be greater than 0 "+ + "(an unbounded pool can exhaust PostgreSQL's max_connections)") + } + if c.Database.MaxIdleConns > c.Database.MaxOpenConns { + problems = append(problems, fmt.Sprintf( + "DB_MAX_IDLE_CONNS (%d) must not exceed DB_MAX_OPEN_CONNS (%d)", + c.Database.MaxIdleConns, c.Database.MaxOpenConns)) + } + if c.Server.Port == "" { + problems = append(problems, "PORT must not be empty") + } + + // Every listener timeout must be positive. net/http reads zero as "no + // deadline", so an explicit HTTP_READ_TIMEOUT=0s silently restores the + // unbounded-connection exposure these settings exist to close — and + // HTTP_SHUTDOWN_TIMEOUT=0s yields an already-expired context, so nothing + // ever drains. Checking only ReadHeaderTimeout left four ways back in. + for _, timeout := range []struct { + name string + value time.Duration + why string + }{ + { + "HTTP_READ_HEADER_TIMEOUT", c.Server.ReadHeaderTimeout, + "zero leaves the server open to slowloris connections", + }, + { + "HTTP_READ_TIMEOUT", c.Server.ReadTimeout, + "zero lets a client hold a connection open indefinitely while sending a body", + }, + { + "HTTP_WRITE_TIMEOUT", c.Server.WriteTimeout, + "zero lets a slow consumer hold a response open indefinitely", + }, + { + "HTTP_IDLE_TIMEOUT", c.Server.IdleTimeout, + "zero lets idle keep-alive connections accumulate without bound", + }, + { + "HTTP_SHUTDOWN_TIMEOUT", c.Server.ShutdownTimeout, + "zero expires the shutdown deadline immediately, so nothing drains and " + + "Jetstream cursors are never flushed", + }, + } { + if timeout.value <= 0 { + problems = append(problems, fmt.Sprintf("%s must be greater than 0 (%s)", + timeout.name, timeout.why)) + } + } + + // A ReadTimeout below ReadHeaderTimeout makes the latter unreachable: the + // whole-request deadline fires first, so the slowloris guard never applies. + if c.Server.ReadTimeout > 0 && c.Server.ReadHeaderTimeout > c.Server.ReadTimeout { + problems = append(problems, fmt.Sprintf( + "HTTP_READ_HEADER_TIMEOUT (%s) must not exceed HTTP_READ_TIMEOUT (%s)", + c.Server.ReadHeaderTimeout, c.Server.ReadTimeout)) + } + + if c.Instance.Domain == "" { + problems = append(problems, + "INSTANCE_DOMAIN is required when INSTANCE_DID is not a did:web DID") + } + if !strings.HasPrefix(c.Instance.DID, "did:") { + // This becomes the audience every aggregator service JWT is validated + // against, so a non-DID value fails every aggregator request at + // runtime rather than at startup. + problems = append(problems, fmt.Sprintf( + "INSTANCE_DID must be a DID (got %q)", c.Instance.DID)) + } + + if !c.IsDevEnv { + switch { + case c.OAuth.SealSecret == "": + problems = append(problems, "OAUTH_SEAL_SECRET is required in production") + case isPlaceholder(c.OAuth.SealSecret): + problems = append(problems, "OAUTH_SEAL_SECRET is still set to a documented "+ + "placeholder value; generate one with: openssl rand -base64 32") + default: + // Checked here rather than left to oauth.NewOAuthClient, which + // runs after schema migrations have already been applied. A + // config error should stop the process before it changes + // anything. + decoded, err := base64.StdEncoding.DecodeString(c.OAuth.SealSecret) + if err != nil { + problems = append(problems, "OAUTH_SEAL_SECRET must be base64: "+err.Error()) + } else if len(decoded) != sealSecretBytes { + problems = append(problems, fmt.Sprintf( + "OAUTH_SEAL_SECRET must decode to %d bytes, got %d; "+ + "generate one with: openssl rand -base64 %d", + sealSecretBytes, len(decoded), sealSecretBytes)) + } + } + + switch { + case c.CursorSecret == devCursorSecret: + problems = append(problems, "CURSOR_SECRET is required in production "+ + "(the dev placeholder is public, so anyone could forge pagination cursors)") + case isPlaceholder(c.CursorSecret): + // The shipped .env.prod.example carries CHANGE_ME_CURSOR_SECRET, + // which is every bit as public as the dev constant. Rejecting + // only the latter left the documented placeholder usable. + problems = append(problems, "CURSOR_SECRET is still set to a documented "+ + "placeholder value; generate one with: openssl rand -base64 32") + case len(c.CursorSecret) < minSecretLength: + problems = append(problems, fmt.Sprintf( + "CURSOR_SECRET must be at least %d characters to be a usable HMAC key", + minSecretLength)) + } + + if c.Jetstream.FeedsSpec == "" { + problems = append(problems, "JETSTREAM_FEEDS is required in production "+ + "(the localhost default is dev-only): set semicolon-separated = "+ + "entries, e.g. \"bsky=wss://jetstream2.us-east.bsky.network;self=ws://tidepool-prod-jetstream:8080\"") + } + if c.Instance.SkipDIDWebVerification { + problems = append(problems, "SKIP_DID_WEB_VERIFICATION must not be enabled in production "+ + "(it lets a community claim a hostedBy domain it does not control)") + } + + // The localhost defaults exist for dev. Reaching production with one + // still in place used to boot cleanly and then fail at first use, in + // somebody else's logs: an unset APPVIEW_PUBLIC_URL puts + // http://localhost:8080 into the OAuth client metadata and redirect + // URI, so every login is rejected by the authorization server. + if err := requirePublicHost("APPVIEW_PUBLIC_URL", c.OAuth.PublicURL); err != nil { + problems = append(problems, err.Error()) + } + if err := requirePublicHost("PDS_URL", c.PDS.URL); err != nil { + problems = append(problems, err.Error()) + } + } + + if len(problems) == 0 { + return nil + } + return errors.New("invalid configuration:\n - " + strings.Join(problems, "\n - ")) +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..c8a0ba4 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,967 @@ +package config + +import ( + "bytes" + "encoding/base64" + "net/url" + "strings" + "testing" + "time" +) + +// validSealSecret is a well-formed OAUTH_SEAL_SECRET: base64 of exactly 32 +// bytes, which is what oauth.NewOAuthClient requires. +var validSealSecret = base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0xA5}, 32)) + +// prodEnv is the minimum set of variables a production configuration must +// provide. Tests start from this and mutate one thing at a time so each case +// asserts about exactly one rule. +func prodEnv(t *testing.T) { + t.Helper() + t.Setenv("IS_DEV_ENV", "false") + t.Setenv("DATABASE_URL", "postgres://u:p@db:5432/coves?sslmode=disable") + t.Setenv("OAUTH_SEAL_SECRET", validSealSecret) + t.Setenv("CURSOR_SECRET", "a-real-cursor-secret-long-enough") + t.Setenv("JETSTREAM_FEEDS", "bsky=wss://jetstream2.us-east.bsky.network") + t.Setenv("INSTANCE_DID", "did:web:coves.social") + t.Setenv("APPVIEW_PUBLIC_URL", "https://coves.social") + t.Setenv("PDS_URL", "https://pds.coves.social") +} + +// clearEnv delegates to the exported helper so there is exactly one list of +// the variables Load reads. +func clearEnv(t *testing.T) { + t.Helper() + ClearEnvForTest(t) +} + +func TestLoad_DevDefaults(t *testing.T) { + clearEnv(t) + t.Setenv("IS_DEV_ENV", "true") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + + if !cfg.IsDevEnv { + t.Error("IsDevEnv should be true") + } + if cfg.Server.Port != "8080" { + t.Errorf("Server.Port = %q, want %q", cfg.Server.Port, "8080") + } + if cfg.PDS.URL != "http://localhost:3001" { + t.Errorf("PDS.URL = %q, want the local dev PDS", cfg.PDS.URL) + } + if cfg.Jetstream.FeedsSpec != "self=ws://localhost:6008" { + t.Errorf("Jetstream.FeedsSpec = %q, want the local dev feed", cfg.Jetstream.FeedsSpec) + } + if cfg.CursorSecret != devCursorSecret { + t.Errorf("CursorSecret = %q, want the dev placeholder", cfg.CursorSecret) + } + if !cfg.OAuth.SealSecretGenerated { + t.Error("OAuth.SealSecretGenerated should be true when OAUTH_SEAL_SECRET is unset in dev") + } + if cfg.OAuth.SealSecret == "" { + t.Error("OAuth.SealSecret should be generated in dev, not left empty") + } + if cfg.Instance.Domain != "coves.social" { + t.Errorf("Instance.Domain = %q, want it derived from the default did:web", cfg.Instance.Domain) + } +} + +// The whole point of item 1: none of these may be zero, because a zero timeout +// in net/http means "wait forever". +func TestLoad_ServerTimeoutsAreNeverZero(t *testing.T) { + clearEnv(t) + t.Setenv("IS_DEV_ENV", "true") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + + timeouts := map[string]time.Duration{ + "ReadHeaderTimeout": cfg.Server.ReadHeaderTimeout, + "ReadTimeout": cfg.Server.ReadTimeout, + "WriteTimeout": cfg.Server.WriteTimeout, + "IdleTimeout": cfg.Server.IdleTimeout, + "ShutdownTimeout": cfg.Server.ShutdownTimeout, + } + for name, value := range timeouts { + if value <= 0 { + t.Errorf("Server.%s = %v, must be positive: zero means no timeout at all", name, value) + } + } + + // The image proxy may spend its full 30s fetch timeout on a remote PDS + // before writing anything, so a shorter WriteTimeout would truncate + // legitimate responses. + if cfg.Server.WriteTimeout <= 30*time.Second { + t.Errorf("Server.WriteTimeout = %v, must exceed the image proxy's 30s fetch timeout", + cfg.Server.WriteTimeout) + } + if cfg.Server.ReadHeaderTimeout > cfg.Server.ReadTimeout { + t.Errorf("ReadHeaderTimeout (%v) should not exceed ReadTimeout (%v)", + cfg.Server.ReadHeaderTimeout, cfg.Server.ReadTimeout) + } +} + +func TestLoad_DatabasePoolDefaults(t *testing.T) { + clearEnv(t) + t.Setenv("IS_DEV_ENV", "true") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + + if cfg.Database.MaxOpenConns <= 0 { + t.Errorf("MaxOpenConns = %d, must be bounded", cfg.Database.MaxOpenConns) + } + // PostgreSQL ships with max_connections = 100. Leave headroom for psql + // and the cmd/ maintenance tools. + if cfg.Database.MaxOpenConns > 50 { + t.Errorf("MaxOpenConns = %d, too close to PostgreSQL's default max_connections of 100", + cfg.Database.MaxOpenConns) + } + // database/sql defaults idle to 2, which makes the pool reconnect under + // exactly the concurrency it exists to absorb. + if cfg.Database.MaxIdleConns != cfg.Database.MaxOpenConns { + t.Errorf("MaxIdleConns = %d, want it to match MaxOpenConns (%d) to avoid connection churn", + cfg.Database.MaxIdleConns, cfg.Database.MaxOpenConns) + } + if cfg.Database.ConnMaxLifetime <= 0 { + t.Error("ConnMaxLifetime must be set so the pool recovers from a PostgreSQL restart") + } + if cfg.Database.StatementTimeout <= 0 { + t.Error("StatementTimeout must be set so a runaway query cannot pin a connection") + } +} + +// The single most important property in this package: an environment that +// says nothing about IS_DEV_ENV must be treated as production. If the default +// ever flipped, every production guard below would quietly stop applying while +// the rest of the suite stayed green. +func TestLoad_UnsetIsDevEnvMeansProduction(t *testing.T) { + clearEnv(t) + t.Setenv("DATABASE_URL", "postgres://u:p@db:5432/coves") + // IS_DEV_ENV deliberately left blank. + + _, err := Load() + if err == nil { + t.Fatal("Load() succeeded with IS_DEV_ENV unset; an unset value must mean production") + } + for _, want := range []string{"OAUTH_SEAL_SECRET", "CURSOR_SECRET", "JETSTREAM_FEEDS"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error should mention %s; got:\n%s", want, err.Error()) + } + } +} + +// Validate's own guards must be reachable, not merely implied by the defaults. +// Every one of these values parses cleanly, so only Validate stands between it +// and a running server. +func TestLoad_RejectsExplicitlyDisabledGuards(t *testing.T) { + tests := []struct { + name string + key string + value string + wantText string + }{ + { + name: "zero read header timeout reopens slowloris", + key: "HTTP_READ_HEADER_TIMEOUT", value: "0s", wantText: "slowloris", + }, + { + name: "zero read timeout", key: "HTTP_READ_TIMEOUT", value: "0s", + wantText: "HTTP_READ_TIMEOUT must be greater than 0", + }, + { + name: "zero write timeout", key: "HTTP_WRITE_TIMEOUT", value: "0s", + wantText: "HTTP_WRITE_TIMEOUT must be greater than 0", + }, + { + name: "zero idle timeout", key: "HTTP_IDLE_TIMEOUT", value: "0s", + wantText: "HTTP_IDLE_TIMEOUT must be greater than 0", + }, + { + // An already-expired shutdown deadline means nothing ever drains + // and Jetstream cursors are never flushed. + name: "zero shutdown timeout", key: "HTTP_SHUTDOWN_TIMEOUT", value: "0s", + wantText: "HTTP_SHUTDOWN_TIMEOUT must be greater than 0", + }, + { + name: "zero max open conns leaves the pool unbounded", + key: "DB_MAX_OPEN_CONNS", value: "0", + wantText: "DB_MAX_OPEN_CONNS must be greater than 0", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv(tc.key, tc.value) + + _, err := Load() + if err == nil { + t.Fatalf("Load() accepted %s=%q", tc.key, tc.value) + } + if !strings.Contains(err.Error(), tc.wantText) { + t.Errorf("error = %q, want it to mention %q", err.Error(), tc.wantText) + } + }) + } +} + +// A ReadTimeout below ReadHeaderTimeout makes the slowloris guard unreachable: +// the whole-request deadline fires first. +func TestLoad_RejectsReadHeaderTimeoutExceedingReadTimeout(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv("HTTP_READ_HEADER_TIMEOUT", "60s") + t.Setenv("HTTP_READ_TIMEOUT", "30s") + + _, err := Load() + if err == nil { + t.Fatal("Load() accepted a ReadHeaderTimeout larger than ReadTimeout") + } + if !strings.Contains(err.Error(), "HTTP_READ_HEADER_TIMEOUT") { + t.Errorf("error = %q, want it to mention HTTP_READ_HEADER_TIMEOUT", err.Error()) + } +} + +// The .env.prod.example placeholders are published in this repository, so a +// deployment still carrying one has no secret at all — the check must catch +// them, not just the dev constant. +func TestLoad_RejectsDocumentedPlaceholderSecrets(t *testing.T) { + tests := []struct { + key string + value string + }{ + {"CURSOR_SECRET", "CHANGE_ME_CURSOR_SECRET"}, + {"OAUTH_SEAL_SECRET", "CHANGE_ME_BASE64_32_BYTES"}, + } + for _, tc := range tests { + t.Run(tc.key, func(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv(tc.key, tc.value) + + _, err := Load() + if err == nil { + t.Fatalf("Load() accepted the shipped placeholder %s=%q", tc.key, tc.value) + } + if !strings.Contains(err.Error(), "placeholder") { + t.Errorf("error = %q, want it to name the value as a placeholder", err.Error()) + } + }) + } +} + +// Checked in config rather than left to oauth.NewOAuthClient, which runs only +// after schema migrations have been applied. +func TestLoad_ValidatesSealSecretShape(t *testing.T) { + tests := []struct { + name string + value string + wantText string + }{ + {"not base64", "not!valid!base64!", "must be base64"}, + {"wrong length", base64.StdEncoding.EncodeToString([]byte("too short")), "must decode to 32 bytes"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv("OAUTH_SEAL_SECRET", tc.value) + + _, err := Load() + if err == nil { + t.Fatalf("Load() accepted OAUTH_SEAL_SECRET=%q", tc.value) + } + if !strings.Contains(err.Error(), tc.wantText) { + t.Errorf("error = %q, want it to mention %q", err.Error(), tc.wantText) + } + }) + } +} + +// A production deploy that never replaced the localhost defaults used to boot +// clean and then fail at first login — in the authorization server's logs, not +// ours. +func TestLoad_RejectsLocalhostURLsInProduction(t *testing.T) { + tests := []struct { + key string + value string + }{ + {"APPVIEW_PUBLIC_URL", "http://localhost:8080"}, + {"APPVIEW_PUBLIC_URL", "http://127.0.0.1:8080"}, + {"PDS_URL", "http://localhost:3001"}, + } + for _, tc := range tests { + t.Run(tc.key+"="+tc.value, func(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv(tc.key, tc.value) + + _, err := Load() + if err == nil { + t.Fatalf("Load() accepted %s=%q in production", tc.key, tc.value) + } + if !strings.Contains(err.Error(), tc.key) { + t.Errorf("error = %q, want it to mention %s", err.Error(), tc.key) + } + }) + } + + // Dev is where those defaults belong, so they must still be accepted. + t.Run("allowed in dev", func(t *testing.T) { + clearEnv(t) + t.Setenv("IS_DEV_ENV", "true") + if _, err := Load(); err != nil { + t.Fatalf("dev Load() rejected the localhost defaults: %v", err) + } + }) +} + +// INSTANCE_DID becomes the audience every aggregator service JWT is validated +// against, so a non-DID value fails every aggregator request at runtime. +func TestLoad_RejectsNonDIDInstanceDID(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv("INSTANCE_DID", "coves.social") + t.Setenv("INSTANCE_DOMAIN", "coves.social") + + _, err := Load() + if err == nil { + t.Fatal("Load() accepted an INSTANCE_DID that is not a DID") + } + if !strings.Contains(err.Error(), "INSTANCE_DID") { + t.Errorf("error = %q, want it to mention INSTANCE_DID", err.Error()) + } +} + +func TestLoad_ProductionRequiresSecrets(t *testing.T) { + tests := []struct { + name string + unset string + wantText string + }{ + {"missing seal secret", "OAUTH_SEAL_SECRET", "OAUTH_SEAL_SECRET is required"}, + {"missing cursor secret", "CURSOR_SECRET", "CURSOR_SECRET is required"}, + {"missing jetstream feeds", "JETSTREAM_FEEDS", "JETSTREAM_FEEDS is required"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv(tc.unset, "") + + _, err := Load() + if err == nil { + t.Fatalf("Load() succeeded without %s; production must fail closed", tc.unset) + } + if !strings.Contains(err.Error(), tc.wantText) { + t.Errorf("error = %q, want it to mention %q", err.Error(), tc.wantText) + } + }) + } +} + +// The dev placeholder secret is in the repository, so accepting it in +// production would let anyone forge a signed pagination cursor. +func TestLoad_ProductionRejectsDevCursorSecret(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv("CURSOR_SECRET", devCursorSecret) + + _, err := Load() + if err == nil { + t.Fatal("Load() accepted the dev cursor secret in production") + } + if !strings.Contains(err.Error(), "CURSOR_SECRET") { + t.Errorf("error = %q, want it to mention CURSOR_SECRET", err.Error()) + } +} + +func TestLoad_ProductionRejectsSkippedDIDWebVerification(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv("SKIP_DID_WEB_VERIFICATION", "true") + + _, err := Load() + if err == nil { + t.Fatal("Load() allowed did:web verification to be skipped in production") + } + if !strings.Contains(err.Error(), "SKIP_DID_WEB_VERIFICATION") { + t.Errorf("error = %q, want it to mention SKIP_DID_WEB_VERIFICATION", err.Error()) + } +} + +func TestLoad_ProductionSucceedsWithFullConfig(t *testing.T) { + clearEnv(t) + prodEnv(t) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error for a complete production config: %v", err) + } + if cfg.IsDevEnv { + t.Error("IsDevEnv should be false") + } + if cfg.OAuth.SealSecretGenerated { + t.Error("SealSecretGenerated should be false when OAUTH_SEAL_SECRET is provided") + } +} + +func TestLoad_LegacyJetstreamVarsAreRejected(t *testing.T) { + for _, legacy := range legacyJetstreamVars { + t.Run(legacy, func(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv(legacy, "wss://jetstream2.us-east.bsky.network") + + _, err := Load() + if err == nil { + t.Fatalf("Load() ignored legacy variable %s", legacy) + } + if !strings.Contains(err.Error(), legacy) { + t.Errorf("error = %q, want it to name %s", err.Error(), legacy) + } + if !strings.Contains(err.Error(), "JETSTREAM_FEEDS") { + t.Errorf("error = %q, want it to point at JETSTREAM_FEEDS", err.Error()) + } + }) + } +} + +func TestLoad_InstanceDomain(t *testing.T) { + tests := []struct { + name string + did string + domain string + wantDomain string + wantErr bool + }{ + { + name: "did:web derives its own domain", + did: "did:web:example.social", + wantDomain: "example.social", + }, + { + // A did:web instance must not be able to mint handles under + // someone else's domain, so INSTANCE_DOMAIN cannot override it. + name: "did:web ignores a conflicting INSTANCE_DOMAIN", + did: "did:web:example.social", + domain: "riotgames.com", + wantDomain: "example.social", + }, + { + name: "did:plc uses INSTANCE_DOMAIN", + did: "did:plc:abc123", + domain: "example.social", + wantDomain: "example.social", + }, + { + name: "did:plc without INSTANCE_DOMAIN is rejected", + did: "did:plc:abc123", + wantErr: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv("INSTANCE_DID", tc.did) + t.Setenv("INSTANCE_DOMAIN", tc.domain) + + cfg, err := Load() + if tc.wantErr { + if err == nil { + t.Fatal("Load() succeeded, want an error") + } + return + } + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.Instance.Domain != tc.wantDomain { + t.Errorf("Instance.Domain = %q, want %q", cfg.Instance.Domain, tc.wantDomain) + } + }) + } +} + +func TestLoad_IdentityPLCResolution(t *testing.T) { + t.Run("dev forces the resolver onto the local PLC", func(t *testing.T) { + clearEnv(t) + t.Setenv("IS_DEV_ENV", "true") + t.Setenv("PLC_DIRECTORY_URL", "http://localhost:3002") + // Must be ignored in dev: resolving against a different PLC than the + // one registration writes to breaks end-to-end tests. + t.Setenv("IDENTITY_PLC_URL", "https://plc.directory") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.Identity.ResolverPLCURL != "http://localhost:3002" { + t.Errorf("ResolverPLCURL = %q, want the local PLC in dev", cfg.Identity.ResolverPLCURL) + } + }) + + t.Run("production honours IDENTITY_PLC_URL", func(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv("PLC_DIRECTORY_URL", "https://plc.directory") + t.Setenv("IDENTITY_PLC_URL", "https://plc-mirror.internal") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.Identity.ResolverPLCURL != "https://plc-mirror.internal" { + t.Errorf("ResolverPLCURL = %q, want the configured mirror", cfg.Identity.ResolverPLCURL) + } + if cfg.Identity.PLCURL != "https://plc.directory" { + t.Errorf("PLCURL = %q, want the primary directory", cfg.Identity.PLCURL) + } + }) +} + +func TestLoad_MalformedValuesAreRejected(t *testing.T) { + tests := []struct { + key string + value string + }{ + {"IS_DEV_ENV", "yes"}, + {"DB_MAX_OPEN_CONNS", "lots"}, + {"DB_MAX_OPEN_CONNS", "-1"}, + {"HTTP_READ_TIMEOUT", "30"}, // missing a unit + {"IDENTITY_CACHE_TTL", "forever"}, + {"SKIP_DID_WEB_VERIFICATION", "1.5"}, + } + for _, tc := range tests { + t.Run(tc.key+"="+tc.value, func(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv(tc.key, tc.value) + + _, err := Load() + if err == nil { + t.Fatalf("Load() accepted %s=%q", tc.key, tc.value) + } + // Assert the reason, not just that something failed — otherwise a + // Load() broken for an unrelated reason still passes this test. + if !strings.Contains(err.Error(), tc.key) { + t.Errorf("error = %q, want it to name %s", err.Error(), tc.key) + } + }) + } +} + +func TestLoad_IdleConnsMayNotExceedOpenConns(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv("DB_MAX_OPEN_CONNS", "10") + t.Setenv("DB_MAX_IDLE_CONNS", "20") + + _, err := Load() + if err == nil { + t.Fatal("Load() accepted MaxIdleConns greater than MaxOpenConns") + } + if !strings.Contains(err.Error(), "DB_MAX_IDLE_CONNS") { + t.Errorf("error = %q, want it to mention DB_MAX_IDLE_CONNS", err.Error()) + } +} + +// Validate reports every problem at once so a misconfigured deployment can be +// fixed in one pass rather than one restart per mistake. +func TestValidate_ReportsAllProblems(t *testing.T) { + cfg := &Config{ + IsDevEnv: false, + Database: DatabaseConfig{URL: "postgres://u:p@db/coves", MaxOpenConns: 25, MaxIdleConns: 25}, + Server: ServerConfig{Port: "8080", ReadHeaderTimeout: 10 * time.Second}, + Instance: InstanceConfig{DID: "did:plc:abc"}, // no Domain + CursorSecret: devCursorSecret, + // no OAuth.SealSecret, no Jetstream.FeedsSpec + } + + err := cfg.Validate() + if err == nil { + t.Fatal("Validate() returned nil for an invalid production config") + } + for _, want := range []string{ + "INSTANCE_DOMAIN", "OAUTH_SEAL_SECRET", "CURSOR_SECRET", "JETSTREAM_FEEDS", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("error should mention %s; got:\n%s", want, err.Error()) + } + } +} + +func TestLoad_CSVListsAreTrimmed(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv("COMMUNITY_CREATORS", " did:plc:one , did:plc:two ,, ") + t.Setenv("TRUSTED_BRIDGE_PDS_HOSTS", "bridge.example.com") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + + want := []string{"did:plc:one", "did:plc:two"} + got := cfg.Instance.AllowedCommunityCreators + if len(got) != len(want) { + t.Fatalf("AllowedCommunityCreators = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("AllowedCommunityCreators[%d] = %q, want %q", i, got[i], want[i]) + } + } + + // nil vs empty matters: nil means "unrestricted", not "nobody". + t.Setenv("COMMUNITY_CREATORS", "") + cfg, err = Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.Instance.AllowedCommunityCreators != nil { + t.Errorf("AllowedCommunityCreators = %v, want nil when unset (unrestricted)", + cfg.Instance.AllowedCommunityCreators) + } +} + +func TestLoad_PortFallsBackToLegacyName(t *testing.T) { + clearEnv(t) + prodEnv(t) + t.Setenv("APPVIEW_PORT", "9090") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.Server.Port != "9090" { + t.Errorf("Server.Port = %q, want the APPVIEW_PORT fallback", cfg.Server.Port) + } + + // PORT wins when both are set. + t.Setenv("PORT", "8081") + cfg, err = Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.Server.Port != "8081" { + t.Errorf("Server.Port = %q, want PORT to take precedence", cfg.Server.Port) + } +} + +func TestAppDSN(t *testing.T) { + tests := []struct { + name string + dsn string + timeout time.Duration + check func(t *testing.T, got string) + }{ + { + name: "url form gains statement_timeout in milliseconds", + dsn: "postgres://u:p@db:5432/coves?sslmode=disable", + timeout: 30 * time.Second, + check: func(t *testing.T, got string) { + parsed, err := url.Parse(got) + if err != nil { + t.Fatalf("result is not a valid URL: %v", err) + } + if v := parsed.Query().Get("statement_timeout"); v != "30000" { + t.Errorf("statement_timeout = %q, want %q", v, "30000") + } + if v := parsed.Query().Get("sslmode"); v != "disable" { + t.Errorf("sslmode = %q, existing parameters must be preserved", v) + } + }, + }, + { + name: "an explicit statement_timeout is left alone", + dsn: "postgres://u:p@db:5432/coves?statement_timeout=5000", + timeout: 30 * time.Second, + check: func(t *testing.T, got string) { + parsed, _ := url.Parse(got) + if v := parsed.Query().Get("statement_timeout"); v != "5000" { + t.Errorf("statement_timeout = %q, want the operator's %q", v, "5000") + } + }, + }, + { + name: "keyword form gains statement_timeout", + dsn: "host=db user=u dbname=coves sslmode=disable", + timeout: 15 * time.Second, + check: func(t *testing.T, got string) { + if !strings.Contains(got, "statement_timeout=15000") { + t.Errorf("result = %q, want statement_timeout=15000", got) + } + if !strings.Contains(got, "host=db") { + t.Errorf("result = %q, existing keywords must be preserved", got) + } + }, + }, + { + name: "keyword form with an existing statement_timeout is left alone", + dsn: "host=db statement_timeout=2000", + timeout: 15 * time.Second, + check: func(t *testing.T, got string) { + if strings.Contains(got, "15000") { + t.Errorf("result = %q, want the operator's 2000 preserved", got) + } + }, + }, + { + name: "zero timeout leaves the DSN untouched", + dsn: "postgres://u:p@db:5432/coves", + timeout: 0, + check: func(t *testing.T, got string) { + if got != "postgres://u:p@db:5432/coves" { + t.Errorf("result = %q, want the DSN unchanged", got) + } + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d := DatabaseConfig{URL: tc.dsn, StatementTimeout: tc.timeout} + got, err := d.AppDSN() + if err != nil { + t.Fatalf("AppDSN() returned error: %v", err) + } + tc.check(t, got) + }) + } +} + +// Migrations must not inherit the request-path statement timeout: a CREATE +// INDEX killed halfway is worse than a slow one. +func TestMigrationDSN_HasNoStatementTimeout(t *testing.T) { + d := DatabaseConfig{ + URL: "postgres://u:p@db:5432/coves?sslmode=disable", + StatementTimeout: 30 * time.Second, + } + got, err := d.MigrationDSN() + if err != nil { + t.Fatalf("MigrationDSN() returned error: %v", err) + } + if got != d.URL { + t.Errorf("MigrationDSN() = %q, want the URL unchanged (%q)", got, d.URL) + } + + // An operator-set statement_timeout must be stripped too: otherwise the + // no-timeout guarantee holds only for the timeout this package adds. + withOperatorTimeout := DatabaseConfig{ + URL: "postgres://u:p@db:5432/coves?sslmode=disable&statement_timeout=5000", + StatementTimeout: 30 * time.Second, + } + got, err = withOperatorTimeout.MigrationDSN() + if err != nil { + t.Fatalf("MigrationDSN() returned error: %v", err) + } + if strings.Contains(got, "statement_timeout") { + t.Errorf("MigrationDSN() = %q, must not carry any statement timeout", got) + } + if !strings.Contains(got, "sslmode=disable") { + t.Errorf("MigrationDSN() = %q, other parameters must be preserved", got) + } +} + +func TestAppDSN_EmptyURL(t *testing.T) { + // Both with and without a statement timeout: the early return for a + // disabled timeout used to skip the emptiness check entirely, so + // DatabaseConfig{} yielded ("", nil) and sql.Open then fell back to + // PGHOST/PGUSER or the OS username. + for _, timeout := range []time.Duration{time.Second, 0} { + d := DatabaseConfig{URL: " ", StatementTimeout: timeout} + if _, err := d.AppDSN(); err == nil { + t.Errorf("AppDSN() accepted an empty database URL (StatementTimeout=%v)", timeout) + } + if _, err := d.MigrationDSN(); err == nil { + t.Errorf("MigrationDSN() accepted an empty database URL (StatementTimeout=%v)", timeout) + } + } +} + +// PostgreSQL reads statement_timeout=0 as "no limit", so rounding a +// sub-millisecond setting down to zero would silently disable the bound +// instead of tightening it. +func TestAppDSN_SubMillisecondTimeoutClampsUp(t *testing.T) { + d := DatabaseConfig{ + URL: "postgres://u:p@db:5432/coves", + StatementTimeout: 500 * time.Microsecond, + } + got, err := d.AppDSN() + if err != nil { + t.Fatalf("AppDSN() returned error: %v", err) + } + parsed, err := url.Parse(got) + if err != nil { + t.Fatalf("result is not a valid URL: %v", err) + } + if v := parsed.Query().Get("statement_timeout"); v != "1" { + t.Errorf("statement_timeout = %q, want %q; 0 would disable the timeout entirely", v, "1") + } +} + +func TestAppDSN_MalformedURLIsRejected(t *testing.T) { + d := DatabaseConfig{ + URL: "postgres://u:p@db:notaport/coves", + StatementTimeout: time.Second, + } + if _, err := d.AppDSN(); err == nil { + t.Fatal("AppDSN() accepted a malformed URL instead of returning an error") + } +} + +// The URL is re-encoded when the parameter is added, so reserved characters +// must survive the round trip intact — a mangled password silently breaks +// authentication. +func TestAppDSN_PreservesEscapedCredentialsAndParams(t *testing.T) { + d := DatabaseConfig{ + URL: "postgres://user:p%40ss%2Fword@db:5432/coves?search_path=public,app&sslmode=require", + StatementTimeout: 30 * time.Second, + } + got, err := d.AppDSN() + if err != nil { + t.Fatalf("AppDSN() returned error: %v", err) + } + parsed, err := url.Parse(got) + if err != nil { + t.Fatalf("result is not a valid URL: %v", err) + } + + password, _ := parsed.User.Password() + if password != "p@ss/word" { + t.Errorf("password = %q, want %q", password, "p@ss/word") + } + if v := parsed.Query().Get("search_path"); v != "public,app" { + t.Errorf("search_path = %q, want %q", v, "public,app") + } + if v := parsed.Query().Get("sslmode"); v != "require" { + t.Errorf("sslmode = %q, want %q", v, "require") + } + if v := parsed.Query().Get("statement_timeout"); v != "30000" { + t.Errorf("statement_timeout = %q, want %q", v, "30000") + } +} + +// libpq allows single-quoted values containing spaces. Splitting on +// whitespace alone turns "options='-c statement_timeout=1000'" into a field +// that parses as a bare statement_timeout keyword, so the parameter would be +// wrongly treated as already set. +func TestAppDSN_KeywordFormWithQuotedValue(t *testing.T) { + d := DatabaseConfig{ + URL: "host=db user=u options='-c default_transaction_read_only=on'", + StatementTimeout: 15 * time.Second, + } + got, err := d.AppDSN() + if err != nil { + t.Fatalf("AppDSN() returned error: %v", err) + } + if !strings.Contains(got, "statement_timeout=15000") { + t.Errorf("result = %q, want statement_timeout=15000 appended", got) + } + if !strings.Contains(got, "options='-c default_transaction_read_only=on'") { + t.Errorf("result = %q, the quoted value must survive intact", got) + } + + // And the inverse: a statement_timeout hidden inside a quoted options + // value is not the connection parameter, so it must not suppress ours. + hidden := DatabaseConfig{ + URL: "host=db options='-c statement_timeout=1000'", + StatementTimeout: 15 * time.Second, + } + got, err = hidden.AppDSN() + if err != nil { + t.Fatalf("AppDSN() returned error: %v", err) + } + if !strings.Contains(got, "statement_timeout=15000") { + t.Errorf("result = %q, a quoted options value must not be mistaken for the parameter", got) + } +} + +func TestMigrationDSN_StripsKeywordFormTimeout(t *testing.T) { + d := DatabaseConfig{URL: "host=db user=u statement_timeout=2000 sslmode=disable"} + got, err := d.MigrationDSN() + if err != nil { + t.Fatalf("MigrationDSN() returned error: %v", err) + } + if strings.Contains(got, "statement_timeout") { + t.Errorf("MigrationDSN() = %q, must not carry a statement timeout", got) + } + for _, want := range []string{"host=db", "user=u", "sslmode=disable"} { + if !strings.Contains(got, want) { + t.Errorf("MigrationDSN() = %q, must preserve %q", got, want) + } + } +} + +func TestPDSConfig_HasInstanceCredentials(t *testing.T) { + tests := []struct { + name string + handle string + password string + want bool + }{ + {"both set", "coves.social", "secret", true}, + {"handle only", "coves.social", "", false}, + {"password only", "", "secret", false}, + {"neither", "", "", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := PDSConfig{InstanceHandle: tc.handle, InstancePassword: tc.password} + if got := p.HasInstanceCredentials(); got != tc.want { + t.Errorf("HasInstanceCredentials() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestSignupConfig_TokenEndpointEnabled(t *testing.T) { + tests := []struct { + name string + secret string + adminPassword string + want bool + }{ + {"both set", "turnstile-secret", "admin-password", true}, + {"captcha secret missing", "", "admin-password", false}, + {"admin password missing", "turnstile-secret", "", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := SignupConfig{TurnstileSecretKey: tc.secret} + if got := s.TokenEndpointEnabled(tc.adminPassword); got != tc.want { + t.Errorf("TokenEndpointEnabled() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestLoad_TrimsWhitespace(t *testing.T) { + clearEnv(t) + prodEnv(t) + // Compose files and .env files routinely leave trailing spaces. + t.Setenv("IS_DEV_ENV", " false ") + t.Setenv("PDS_URL", " http://pds.example.com ") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if cfg.PDS.URL != "http://pds.example.com" { + t.Errorf("PDS.URL = %q, want it trimmed", cfg.PDS.URL) + } + if cfg.IsDevEnv { + t.Error("IsDevEnv should parse \" false \" as false") + } +} diff --git a/internal/config/dsn.go b/internal/config/dsn.go new file mode 100644 index 0000000..e758d99 --- /dev/null +++ b/internal/config/dsn.go @@ -0,0 +1,186 @@ +package config + +import ( + "errors" + "fmt" + "net/url" + "strconv" + "strings" +) + +// statementTimeoutParam is the PostgreSQL runtime parameter that bounds how +// long any single statement may run. lib/pq forwards unrecognised connection +// parameters to the server in the startup packet, so setting it here applies +// to every connection the pool opens without a per-query round trip. +const statementTimeoutParam = "statement_timeout" + +// AppDSN returns the connection string used by the application pool, with +// statement_timeout applied. +// +// Enforcing the bound server-side rather than with a per-query +// context.WithTimeout is deliberate, though not because context cancellation +// does nothing: lib/pq does send a PostgreSQL CancelRequest when a query's +// context is cancelled. That path is simply weaker. It has to dial a second +// connection to deliver the cancellation, it races the query finishing, and it +// does nothing at all if the client process dies outright. statement_timeout +// is enforced by the server itself, needs no extra round trip, and applies to +// every query without each call site remembering a deadline. +// +// An explicit statement_timeout already present in the URL is left alone, so +// an operator can override it per deployment. +func (d DatabaseConfig) AppDSN() (string, error) { + if err := requireURL(d.URL); err != nil { + return "", err + } + if d.StatementTimeout <= 0 { + return d.URL, nil + } + // PostgreSQL reads a bare integer as milliseconds. Clamp up rather than + // down: statement_timeout=0 means "no limit", so rounding a sub-millisecond + // setting to zero would silently disable the bound entirely. + milliseconds := d.StatementTimeout.Milliseconds() + if milliseconds < 1 { + milliseconds = 1 + } + return withConnParam(d.URL, statementTimeoutParam, strconv.FormatInt(milliseconds, 10)) +} + +// MigrationDSN returns the connection string used for schema migrations. +// +// Migrations intentionally run without statement_timeout: a CREATE INDEX or a +// backfill over a large table can legitimately exceed the query bound that is +// right for a request handler, and a migration killed halfway is far worse +// than a slow one. Any statement_timeout the operator put directly in +// DATABASE_URL is stripped here too — otherwise the guarantee would hold only +// for the timeout this package adds, which is the case that needed it least. +func (d DatabaseConfig) MigrationDSN() (string, error) { + if err := requireURL(d.URL); err != nil { + return "", err + } + return withoutConnParam(d.URL, statementTimeoutParam) +} + +// requireURL rejects an empty connection string. Without this, sql.Open("") +// succeeds and libpq quietly falls back to PGHOST/PGUSER or the OS username, +// connecting somewhere nobody intended. +func requireURL(dsn string) error { + if strings.TrimSpace(dsn) == "" { + return errors.New("database URL is empty") + } + return nil +} + +// withConnParam adds a connection parameter to a libpq connection string, +// leaving it untouched if the parameter is already present. +// +// Both accepted forms are handled: the URL form ("postgres://...") that this +// project uses everywhere, and the keyword/value form ("host=... user=...") +// that libpq also accepts, so an operator switching styles does not silently +// lose the timeout. +func withConnParam(dsn, key, value string) (string, error) { + trimmed := strings.TrimSpace(dsn) + if err := requireURL(trimmed); err != nil { + return "", err + } + + if !isURLForm(trimmed) { + if hasKeyword(trimmed, key) { + return trimmed, nil + } + return trimmed + " " + key + "=" + value, nil + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return "", fmt.Errorf("parsing database URL: %w", err) + } + query := parsed.Query() + if query.Has(key) { + return trimmed, nil + } + query.Set(key, value) + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} + +// withoutConnParam removes a connection parameter from a libpq connection +// string, in either accepted form. +func withoutConnParam(dsn, key string) (string, error) { + trimmed := strings.TrimSpace(dsn) + if err := requireURL(trimmed); err != nil { + return "", err + } + + if !isURLForm(trimmed) { + kept := make([]string, 0, 8) + for _, field := range splitKeywordFields(trimmed) { + if name, _, found := strings.Cut(field, "="); found && name == key { + continue + } + kept = append(kept, field) + } + return strings.Join(kept, " "), nil + } + + parsed, err := url.Parse(trimmed) + if err != nil { + return "", fmt.Errorf("parsing database URL: %w", err) + } + query := parsed.Query() + if !query.Has(key) { + return trimmed, nil + } + query.Del(key) + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} + +// isURLForm reports whether dsn uses the URL syntax rather than libpq's +// keyword/value syntax. +func isURLForm(dsn string) bool { + return strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") +} + +// hasKeyword reports whether a keyword/value DSN sets key as a whole keyword. +// Matching on the whole key avoids mistaking a substring for the key — libpq's +// "options=-c statement_timeout=1000" contains the name but does not set it as +// a connection parameter. +func hasKeyword(dsn, key string) bool { + for _, field := range splitKeywordFields(dsn) { + if name, _, found := strings.Cut(field, "="); found && name == key { + return true + } + } + return false +} + +// splitKeywordFields splits a libpq keyword/value connection string into its +// fields, keeping single-quoted values intact. +// +// strings.Fields alone is wrong here: libpq allows quoted values containing +// spaces, so "options='-c statement_timeout=1000'" would split into two +// fields, the second of which parses as a bare statement_timeout keyword. +func splitKeywordFields(dsn string) []string { + var fields []string + var current strings.Builder + quoted := false + + for i := 0; i < len(dsn); i++ { + switch char := dsn[i]; { + case char == '\'': + quoted = !quoted + current.WriteByte(char) + case char == ' ' && !quoted: + if current.Len() > 0 { + fields = append(fields, current.String()) + current.Reset() + } + default: + current.WriteByte(char) + } + } + if current.Len() > 0 { + fields = append(fields, current.String()) + } + return fields +} diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..9d269db --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,97 @@ +// Package config loads and validates the Coves AppView's process configuration +// from the environment. +// +// Every environment variable the server reads is declared here, in one place, +// so that a misconfigured deployment fails at startup with a precise message +// instead of surfacing as a confusing runtime failure. Packages that own a +// self-contained subsystem keep their own loaders (observability.ConfigFromEnv, +// imageproxy.ConfigFromEnv); this package covers the server's own wiring. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" +) + +// lookup reads an environment variable, trimming surrounding whitespace. +// Docker Compose and .env files routinely leave trailing spaces, and a value +// like "true " silently failing a comparison is a miserable thing to debug. +func lookup(key string) string { + return strings.TrimSpace(os.Getenv(key)) +} + +// stringVar returns the value of key, or fallback when it is unset or blank. +func stringVar(key, fallback string) string { + if value := lookup(key); value != "" { + return value + } + return fallback +} + +// boolVar reports whether key is set to a recognised truthy value. Anything +// unparseable is reported as an error rather than silently treated as false — +// "IS_DEV_ENV=yes" quietly meaning "production" is exactly the kind of failure +// that only shows up after deploy. +func boolVar(key string, fallback bool) (bool, error) { + raw := lookup(key) + if raw == "" { + return fallback, nil + } + parsed, err := strconv.ParseBool(raw) + if err != nil { + return false, fmt.Errorf("%s: %q is not a boolean (use true/false)", key, raw) + } + return parsed, nil +} + +// durationVar parses a Go duration string (e.g. "30s", "5m", "1h"). +func durationVar(key string, fallback time.Duration) (time.Duration, error) { + raw := lookup(key) + if raw == "" { + return fallback, nil + } + parsed, err := time.ParseDuration(raw) + if err != nil { + return 0, fmt.Errorf("%s: %q is not a duration (e.g. \"30s\", \"5m\"): %w", key, raw, err) + } + if parsed < 0 { + return 0, fmt.Errorf("%s: %q must not be negative", key, raw) + } + return parsed, nil +} + +// intVar parses a non-negative integer. +func intVar(key string, fallback int) (int, error) { + raw := lookup(key) + if raw == "" { + return fallback, nil + } + parsed, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("%s: %q is not an integer", key, raw) + } + if parsed < 0 { + return 0, fmt.Errorf("%s: %q must not be negative", key, raw) + } + return parsed, nil +} + +// csvVar splits a comma-separated list, dropping empty entries and trimming +// whitespace around each. Returns nil (not an empty slice) when unset, so +// callers can distinguish "not configured" from "configured empty". +func csvVar(key string) []string { + raw := lookup(key) + if raw == "" { + return nil + } + var values []string + for _, part := range strings.Split(raw, ",") { + if part = strings.TrimSpace(part); part != "" { + values = append(values, part) + } + } + return values +} diff --git a/internal/config/testing.go b/internal/config/testing.go new file mode 100644 index 0000000..4bfd584 --- /dev/null +++ b/internal/config/testing.go @@ -0,0 +1,47 @@ +package config + +import "testing" + +// loadedEnvVars is every environment variable Load reads. Keeping the list +// beside the loaders rather than in a test file lets other packages that call +// Load in a test reuse it, so only one list has to stay in sync with the code. +var loadedEnvVars = []string{ + "IS_DEV_ENV", + "DATABASE_URL", + "DB_MAX_OPEN_CONNS", "DB_MAX_IDLE_CONNS", "DB_CONN_MAX_LIFETIME", + "DB_CONN_MAX_IDLE_TIME", "DB_STATEMENT_TIMEOUT", + "PORT", "APPVIEW_PORT", + "HTTP_READ_HEADER_TIMEOUT", "HTTP_READ_TIMEOUT", "HTTP_WRITE_TIMEOUT", + "HTTP_IDLE_TIMEOUT", "HTTP_SHUTDOWN_TIMEOUT", + "PLC_DIRECTORY_URL", "IDENTITY_PLC_URL", "IDENTITY_CACHE_TTL", + "OAUTH_SEAL_SECRET", "APPVIEW_PUBLIC_URL", + "OAUTH_CLIENT_PRIVATE_KEY", "OAUTH_CLIENT_KEY_ID", + "INSTANCE_DID", "INSTANCE_DOMAIN", + "COMMUNITY_CREATORS", "TRUSTED_BRIDGE_PDS_HOSTS", "SKIP_DID_WEB_VERIFICATION", + "PDS_URL", "PDS_INSTANCE_HANDLE", "PDS_INSTANCE_PASSWORD", "PDS_ADMIN_PASSWORD", + "JETSTREAM_FEEDS", + "CURSOR_SECRET", + "TURNSTILE_SITE_KEY", "TURNSTILE_SECRET_KEY", +} + +// ClearEnvForTest blanks every environment variable Load reads, restoring them +// when the test finishes. +// +// Any test that calls Load needs this. Without it the result depends on the +// developer's shell: an exported JETSTREAM_URL left over from before the +// legacy variables were retired makes Load fail, and the test reports a +// failure that has nothing to do with what it was checking. +// +// Note this *sets* the variables empty rather than unsetting them, which is +// equivalent only because this package reads exclusively through lookup and +// never distinguishes unset from empty. Because it uses t.Setenv, a test that +// calls it cannot also call t.Parallel. +func ClearEnvForTest(t *testing.T) { + t.Helper() + for _, name := range loadedEnvVars { + t.Setenv(name, "") + } + for _, name := range legacyJetstreamVars { + t.Setenv(name, "") + } +} diff --git a/internal/db/migrations/embed.go b/internal/db/migrations/embed.go new file mode 100644 index 0000000..9563830 --- /dev/null +++ b/internal/db/migrations/embed.go @@ -0,0 +1,25 @@ +// Package migrations embeds the AppView's goose migration files into the +// binary. +// +// Embedding removes the server's dependency on its working directory for +// migrations. Loading them from the relative path "internal/db/migrations" +// meant the binary only booted when started from the repository root, and it +// forced the container image to reproduce that directory layout around the +// binary — a coupling that breaks silently, at startup, in production. +// +// Static web assets are still served from a relative path (see +// internal/api/routes/web.go), so the working directory still matters for +// those; this removes one such dependency, not all of them. +// +// Tests under tests/integration still read these files from disk by relative +// path, which is fine: they run with the repository checked out and goose's +// base filesystem is only overridden by the server binary. +package migrations + +import "embed" + +// FS holds every migration in this directory, in filename order. Pass it to +// goose.SetBaseFS and then run migrations against ".". +// +//go:embed *.sql +var FS embed.FS -- 2.51.2