//// Startup configuration resolved from the environment, so `main` stays pure //// orchestration. Secrets (SECRET_KEY_BASE, STORE_KEY) are required; the rest //// falls back to dev defaults with a log line. import atproto/constellation import atproto/identity import crate_server/catalog_index import crate_server/catalog_index_postgres import crate_server/discogs_client import crate_server/follow_index import crate_server/follow_index_postgres import crate_server/jetstream_consumer import crate_server/known_users import crate_server/known_users_memory import crate_server/known_users_postgres import crate_server/oauth/sessions.{type Store} import crate_server/oauth/sessions_memory import crate_server/oauth/sessions_postgres import crate_server/readiness import crate_server/sealed_store import crate_server/shelf_index import crate_server/shelf_index_postgres import envoy import gleam/erlang/application import gleam/int import gleam/option.{type Option} import gleam/result import gose import wisp const default_port = 8080 /// Default `identity_cache` TTL: 60 minutes. const default_identity_ttl_seconds = 3600 /// Default `identity_cache` negative TTL: 60 seconds. const default_identity_negative_ttl_seconds = 60 /// Default reconcile-pass interval: 6 hours, in milliseconds. Cheap version /// of ADR 0002's consistency model (decision 2, 2026-07-20): a periodic full /// re-backfill rather than a `rev`-watermark drift check, deferred until /// user count makes full re-pages wasteful. const default_reconcile_interval_ms = 21_600_000 pub fn port() -> Int { env_int("PORT", default_port) } /// How long a resolved identity stays fresh in `identity_cache`. pub fn identity_ttl_seconds() -> Int { env_int("IDENTITY_CACHE_TTL_SECONDS", default_identity_ttl_seconds) } /// How long a failed identity resolution suppresses re-fetching. pub fn identity_negative_ttl_seconds() -> Int { env_int( "IDENTITY_NEGATIVE_TTL_SECONDS", default_identity_negative_ttl_seconds, ) } /// How often the reconcile pass re-backfills every known user. pub fn reconcile_interval_ms() -> Int { env_int("RECONCILE_INTERVAL_MS", default_reconcile_interval_ms) } /// The cookie signing secret. Required; a random fallback would hide /// misconfiguration and drop sessions on every restart. pub fn secret_key_base() -> String { case envoy.get("SECRET_KEY_BASE") { Ok(secret) -> secret Error(Nil) -> panic as "SECRET_KEY_BASE unset: set a long random signing secret" } } pub fn resolver() -> String { envoy.get("SLINGSHOT_URL") |> result.unwrap(identity.default_resolver) } pub fn constellation() -> String { envoy.get("CONSTELLATION_URL") |> result.unwrap(constellation.default_host) } /// The Jetstream host `jetstream_consumer` subscribes to (prototype catalog /// index). Defaults to a public Bluesky-operated instance. pub fn jetstream_url() -> String { envoy.get("JETSTREAM_URL") |> result.unwrap(jetstream_consumer.default_host) } pub fn base_url(port: Int) -> String { case envoy.get("BASE_URL") { Ok(url) -> url Error(Nil) -> { wisp.log_warning( "BASE_URL unset: using the localhost public OAuth client (set BASE_URL to an https origin for the confidential client)", ) // Loopback IP, not "localhost": the atproto dev client redirects there // anyway, and session cookies are host-specific, so every callback // (atproto and Discogs) must land on the host the user browses. "http://127.0.0.1:" <> int.to_string(port) } } } pub fn static_directory() -> String { let priv = application.priv_directory("crate_server") |> result.unwrap("priv") priv <> "/static" } /// Where the `/api/cover` proxy caches fetched blob bytes on disk. Override /// for deploys that want the cache on a mounted volume. pub fn cover_cache_directory() -> String { envoy.get("COVER_CACHE_DIRECTORY") |> result.unwrap("./cover-cache") } /// The session store, the Discogs credential store, the known-users store, /// the catalog index, and the shelf index: one shared Postgres pool, no /// sweep ttl on the durable Discogs tokens. The two credential stores are /// encrypted at rest; known_users and both indexes are public, so none of /// them are sealed. pub fn stores() -> #( Store, Store, known_users.Store, catalog_index.Store, shelf_index.Store, follow_index.Store, readiness.Check, ) { let key = store_key() let #(session_store, discogs_store, known, index, shelf, follows, readiness) = raw_stores() #( sealed_store.wrap(session_store, key), sealed_store.wrap(discogs_store, key), known, index, shelf, follows, readiness, ) } // Required; no plaintext fallback. Generate with `openssl rand -base64 32`. fn store_key() -> gose.Key(String) { case envoy.get("STORE_KEY") { Ok(encoded) -> case sealed_store.key_from_base64(encoded) { Ok(key) -> key Error(e) -> panic as { "STORE_KEY invalid: " <> e } } Error(Nil) -> panic as "STORE_KEY unset: set a base64 32-byte key (openssl rand -base64 32)" } } fn raw_stores() -> #( Store, Store, known_users.Store, catalog_index.Store, shelf_index.Store, follow_index.Store, readiness.Check, ) { case envoy.get("DATABASE_URL") { Ok(url) -> case postgres_stores(url) { Ok(stores) -> stores // A set-but-unusable DATABASE_URL is a deploy bug, not a degraded // mode: the in-memory fallback boots healthy and serves an empty // appview with non-persistent sessions, invisible to k8s probes. Error(e) -> panic as { "DATABASE_URL set but Postgres stores are unavailable: " <> e } } Error(Nil) -> { wisp.log_warning( "DATABASE_URL unset: using in-memory stores (lost on restart)", ) memory_stores() } } } fn postgres_stores( url: String, ) -> Result( #( Store, Store, known_users.Store, catalog_index.Store, shelf_index.Store, follow_index.Store, readiness.Check, ), String, ) { use conn <- result.try(sessions_postgres.connect_pool(url)) use session_store <- result.try(sessions_postgres.table_store( conn, sessions_postgres.oauth_sessions_table, option.Some(sessions.ttl_seconds), )) use discogs_store <- result.try(sessions_postgres.table_store( conn, sessions_postgres.discogs_creds_table, option.None, )) use known <- result.try(known_users_postgres.table_store(conn)) use index <- result.try(catalog_index_postgres.table_store(conn)) use shelf <- result.try(shelf_index_postgres.table_store(conn)) use follows <- result.map(follow_index_postgres.table_store(conn)) #( session_store, discogs_store, known, index, shelf, follows, readiness.postgres(conn), ) } fn memory_stores() -> #( Store, Store, known_users.Store, catalog_index.Store, shelf_index.Store, follow_index.Store, readiness.Check, ) { let assert Ok(session_store) = sessions_memory.start() let assert Ok(discogs_store) = sessions_memory.start() let assert Ok(known) = known_users_memory.start() let assert Ok(index) = catalog_index.start() let assert Ok(shelf) = shelf_index.start() let assert Ok(follows) = follow_index.start() #( session_store, discogs_store, known, index, shelf, follows, readiness.ready(), ) } /// App-level Discogs auth from env. Absent is fine: search still works, just /// without cover thumbnails. pub fn discogs_auth() -> Option(discogs_client.Auth) { case envoy.get("DISCOGS_CONSUMER_KEY"), envoy.get("DISCOGS_CONSUMER_SECRET") { Ok(key), Ok(secret) -> option.Some(discogs_client.Auth(key, secret)) _, _ -> option.None } } fn env_int(name: String, fallback: Int) -> Int { case envoy.get(name) { Ok(value) -> int.parse(value) |> result.unwrap(fallback) Error(Nil) -> fallback } }