Skip to content

Latest commit

 

History

History
464 lines (373 loc) · 34.8 KB

configuration.md

File metadata and controls

464 lines (373 loc) · 34.8 KB

Configuration

Two separate systems configure DataBounty Community, and knowing which one you are looking at saves a lot of confusion:

Where it lives When it takes effect Who changes it
Environment variables .env per app, or the process environment Process restart Whoever deploys
Runtime settings admin_settings table in Postgres Next read — no redeploy An admin in the console

Anything about how the platform behaves — thresholds, window sizes, karma rates, feature switches — is a runtime setting, not an environment variable. Environment variables are for wiring: where the database is, which origins may call the API, which third-party keys exist.


Part 1 — Environment variables (apps/api)

apps/api/src/config.ts reads 63 variables, and a further 32 are read elsewhere in apps/api/src — in worker.ts (the sweep/dispatch intervals), lib/prisma.ts, lib/session-cookie.ts, lib/mcp-rate-limit.ts, lib/google-auth.ts and services/. That is 95 in total, and the tables below are the complete list: anything marked ✱ is not in apps/api/.env.example.

Two variables are deliberately outside config.ts rather than by accident, and both say so at the read site: MEMBER_ANALYTICS_ENABLED is read at route-registration time so a test can flip it before buildApp() without depending on module import order (routes/v1/me.ts), and the publication adapters read their own credentials directly (lib/publication/hugging-face.ts, github.ts) to keep provider config inside the provider.

DATABASE_URL, DIRECT_URL and SHADOW_DATABASE_URL are read by Prisma rather than config.ts — DATABASE_URL by the runtime driver adapter in lib/prisma.ts, DIRECT_URL only by the Prisma CLI via prisma.config.ts.

SMTP_SECURE is read at config.ts:227-229: blank derives implicit TLS from the port (true only for 465), and an explicit true/1 overrides it. It is listed under Email below.

Required

Variable Notes
DATABASE_URL Postgres connection string. Read by Prisma (src/lib/prisma.ts). Nothing starts without it.
DIRECT_URL ✱ Conditionally required. Migration/CLI connection, read only by the Prisma CLI (prisma.config.ts, which prefers it and falls back to DATABASE_URL); the runtime never uses it. Unset is correct for local Postgres and the bundled compose database. Required when DATABASE_URL is a transaction pooler (Supabase port 6543, pgbouncer=true): that pooler cannot hold the session-scoped advisory lock Prisma Migrate takes, and the failure mode is not an error — prisma migrate hangs with nothing useful in the log (observed: no output after 10 minutes). Point it at the session pooler (5432).
SHADOW_DATABASE_URL ✱ unset

That is genuinely the only variable with no fallback. Everything below has a default — including several that must not keep their default in production. Read the next section before deploying.

Secrets that fall back to a development value

These have hardcoded dev defaults published in this repository. In production the server now refuses to start on every one of them — see "Refused at boot in production" below. In development they keep their fallback so a fresh clone runs with no configuration.

Variable Dev fallback What an attacker does with the default
SESSION_SECRET dev_session_secret_databounty_community_32bytes_long Forges any member's session cookie.
ADMIN_SESSION_SECRET dev_admin_session_secret_databounty_community_32bytes Forges an admin-console session.
POOL_SAMPLING_HMAC_SECRET ✱ dev_pool_sampling_hmac_secret_databounty_community Predicts which items the human-audit sampler will select, so submissions can be timed to avoid review.
PROFILE_SOURCE_SECRET ✱ dev_profile_source_secret_databounty_community_32b Forges credential-verification state.

Set all four secrets above to distinct high-entropy values — openssl rand -hex 32. A production deployment missing any of them fails fast with a message naming the variable, instead of running with a publicly known key and no symptom.

Refused at boot in production

NODE_ENV is treated as production for anything other than development or test — not an allowlist of specific production-sounding names. A misspelled or unlisted value (e.g. staging) still triggers every guard below; only development and test are exempt. These throw on startup rather than running in a state where a trust claim would be false, a secret would be publicly known, or a link mailed to a real user would be unusable:

Condition Why it refuses
SESSION_SECRET at its dev default Every member session cookie would be signed with a key published in this repository.
ADMIN_SESSION_SECRET at its dev default Same, for admin-console sessions.
POOL_SAMPLING_HMAC_SECRET at its dev default Human-audit sampling would be computable by anyone reading this repository, so a contributor could predict which submissions are never reviewed.
PROFILE_SOURCE_SECRET at its dev default Credential-connect tokens forgeable and stored provider tokens decryptable.
ADMIN_URL unset Would default to localhost:3002 and misroute every admin-console request to the wrong session cookie.
APP_URL unset Every verification, password-reset and admin-invite email would link to http://localhost:3010. The account is unrecoverable and there is no server-side symptom, because sending succeeds.
LANDING_URL unset Waitlist and public-profile links in outbound email would point at http://localhost:3001.
EXECUTION_SANDBOX_ALLOW_EGRESS=true Contributor code would run with unrestricted network egress.
EXECUTION_SANDBOX_VERIFY_ISOLATION=false The sandbox's applied isolation would never be read back, so every "execution verified" verdict would be unfounded.
E2B_API_KEY is set but the E2B SDK or one of its dependencies cannot load The process would otherwise look healthy at startup, but every queued execution would fail later. Startup instead refuses to boot.

One further guard is environment-independent: the execution timeouts must be ordered inner worst case < command timeout < sandbox lifetime, or startup fails.

Mail is also fail-closed in production. With SMTP_HOST/SMTP_USER unset, a development build falls back to a stream transport that discards the message; in production that fallback throws instead. Silently discarding a verification code or a password reset while returning success to the caller is not an acceptable default.

Server and networking

Variable Default Notes
PORT 4000
HOST ✱ 0.0.0.0
NODE_ENV — Anything other than development/test enables the boot guards above — not just production/prod/live.
LOG_LEVEL ✱ info
TRUST_PROXY ✱ unset Set when behind a load balancer, or client IPs in rate limits and audit logs will all be the proxy's.
BODY_LIMIT_BYTES ✱ 10485760 (10 MB) Request body cap. Large files use the upload flow, not request bodies.
CORS_ORIGINS http://localhost:3000,http://localhost:3001,http://localhost:3002 Comma-separated. Must list every frontend origin you actually run, on the exact port. Miss one and the browser blocks its API calls with no server-side error — the page just sits there empty. This is the single most common "it's broken" report.
PUBLIC_API_BASE_URL http://localhost:<PORT> Public origin of this API (no /v1). Builds the digest-unsubscribe link mailed to users and MCP artifact-upload URLs. Refused at boot in production while still the localhost default.
MCP_PUBLIC_URL falls back to APP_URL, then http://localhost:3010 Public dashboard/MCP gateway origin (no path — /mcp is appended; a value ending in /mcp is normalised). Bound into every MCP OAuth credential as its audience and used for all advertised discovery/OAuth URLs. Refused at boot in production while still a localhost default.
APP_URL http://localhost:3010 Dashboard origin. Used to build links in emails and notifications.
LANDING_URL http://localhost:3001 The public landing app. Owns /domains/:id, /pools and public profiles — the waitlist "your domain is live" email links here, not to APP_URL, which has no /domains route. Refused at boot in production if unset.
ADMIN_URL http://localhost:3002 Admin console origin. Required in production (above).
COOKIE_DOMAIN ✱ unset Cookie Domain attribute. Unset ⇒ host-only cookies, correct when every app is on one host. Set it to the shared parent domain (e.g. .databounty.io) when the dashboard, admin console and API are on different subdomains, or sessions will not be sent cross-subdomain. Read in lib/session-cookie.ts.
COOKIE_SAMESITE ✱ lax Cookie SameSite attribute. Leave at lax unless a cross-site flow genuinely needs none — and none requires HTTPS. Read in lib/session-cookie.ts.
MCP_ALLOWED_ORIGINS ✱ https://chatgpt.com,https://chat.openai.com,https://claude.ai Extra origins accepted on the MCP endpoint, beyond those derived from MCP_PUBLIC_URL/APP_URL. Comma-separated; setting the variable replaces this default list rather than adding to it.
DB_POOL_MAX ✱ driver default Max Postgres connections in the runtime pool (lib/prisma.ts). Lower it behind a session pooler, which multiplies connections per instance.
DB_STATEMENT_TIMEOUT_MS ✱ unset Server-side statement timeout applied to runtime queries (lib/prisma.ts).

Authentication

Variable Default Notes
SESSION_SECRET dev fallback See the warning above.
ADMIN_SESSION_SECRET dev fallback Separate from the member secret on purpose — an admin session must not be derivable from a member one.
GOOGLE_CLIENT_ID ✱ unset Enables "Continue with Google". Email/password signup works without it.
GOOGLE_TOKENINFO_URL ✱ https://oauth2.googleapis.com/tokeninfo Google token-introspection endpoint. Override only to point at a test double.
GOOGLE_AUTH_TIMEOUT_MS ✱ 5000 Timeout for that introspection call.

Email

Email is required for a usable deployment: mutations are gated on a verified email address, and the verification link is delivered by email. With SMTP unset, a new member can sign up but cannot verify, and therefore cannot claim a handle or submit anything.

Variable Default Notes
SMTP_HOST unset Unset ⇒ nothing sends.
SMTP_PORT unset
SMTP_SECURE unset (derived) Implicit TLS on connect (SMTPS). Blank derives it from the port — true only for 465. Set true/1 or false to state it explicitly. Read at config.ts:227-229.
SMTP_USER unset
SMTP_PASS unset
EMAIL_FROM noreply@databounty.io Change this. With most providers a sender you do not control will be rejected or spam-filed.

Object storage — uploaded files

Variable Default Notes
STORAGE_DRIVER ✱ unset (⇒ local) s3 for S3-compatible storage; anything else, including unset, selects the local-directory driver. Only local and s3 exist — any other value throws at first use.
STORAGE_LOCAL_DIR ✱ unset Local driver only. Not suitable for more than one API instance — the second instance cannot see the first's files.
STORAGE_BUCKET ✱ unset
STORAGE_REGION ✱ unset
STORAGE_ENDPOINT ✱ unset Set for S3-compatible services that are not AWS (MinIO, R2, Spaces).
AWS_ACCESS_KEY_ID ✱ unset
AWS_SECRET_ACCESS_KEY ✱ unset
AWS_SESSION_TOKEN ✱ unset Temporary credentials only.
STORAGE_MAX_UPLOAD_BYTES ✱ 104857600 (100 MB) Single-request upload cap.
STORAGE_MULTIPART_THRESHOLD_BYTES ✱ 104857600 (100 MB) Above this, uploads go multipart.
STORAGE_MULTIPART_PART_SIZE_BYTES ✱ 16777216 (16 MB)
STORAGE_MAX_MULTIPART_UPLOAD_BYTES ✱ 5368709120 (5 GB)
STORAGE_REQUEST_TIMEOUT_MS ✱ 15000
STORAGE_DIRECT_UPLOAD_EXPIRES_SECONDS ✱ 900 (15 min) Lifetime of a browser-upload target issued by /v1/artifacts/upload-slot. Shorter is safer; too short and a slow connection cannot finish a large part.
STORAGE_MAX_PENDING_UPLOADS_PER_USER ✱ 20 Cap on simultaneously pending (created but not completed) artifacts per member. Stops one account from reserving unbounded upload slots.

Malware scanning

Variable Default Notes
ARTIFACT_SCAN_URL ✱ unset Scanner endpoint.
ARTIFACT_SCAN_TOKEN ✱ unset
ARTIFACT_SCAN_TIMEOUT_MS ✱ 15000

Unset means uploads are not scanned. The scan status is recorded as not-performed rather than presented as clean — but you are accepting unscanned files from the internet, so configure it.

Scanning is additionally gated by the database setting artifacts.malware_scan.enabled (off by default), read directly by src/services/artifact-scanner.ts. That key is not in the validated settings catalog below and has no admin route or console UI, so it can currently only be enabled by writing the admin_settings row directly. Documented gap.

Execution sandbox

The sandbox runs contributed code. Without a key, the execution stage records no_provider_configured and routes the submission to human review — it never passes an item it could not actually run.

Variable Default Notes
E2B_API_KEY "" Unset ⇒ execution stage fails closed as above.
E2B_TEMPLATE "" Sandbox template id.
EXECUTION_RUNNER_TIMEOUT_MS ✱ 120000
EXECUTION_SANDBOX_ALLOW_EGRESS ✱ false true is refused in production.
EXECUTION_SANDBOX_EGRESS_ALLOWLIST ✱ unset Only meaningful with egress enabled.
EXECUTION_SANDBOX_VERIFY_ISOLATION ✱ true false is refused in production.
EXECUTION_SANDBOX_MAX_CPUS ✱ 2
EXECUTION_SANDBOX_MAX_MEMORY_MB ✱ 4096
EXECUTION_SANDBOX_MAX_FILE_SIZE_MB ✱ 256
EXECUTION_SANDBOX_MAX_PROCESSES ✱ 256
EXECUTION_SANDBOX_MAX_OPEN_FILES ✱ 1024
EXECUTION_SANDBOX_ORDER ✱ e2b Comma-separated provider order tried per run (services/execution-providers/provider-order.ts). An unknown or duplicated name is rejected at boot rather than silently skipped. Only e2b is registered in this rebuild.

LLM review

Variable Default Notes
OPENROUTER_API_KEY unset Unset ⇒ the LLM stage records outcome no_provider_configured with status: "pending_llm_review" in its evidence detail, and routes to human audit. It never auto-accepts. The stage is also off by default as a runtime setting, so the honest path is the default path.
ANTHROPIC_API_KEY ✱ unset Enables the anthropic provider in the multi-provider LLM layer (services/llm/config.ts, providerKey()). Unset ⇒ that provider is simply not selectable; it never degrades into a fabricated review.
OPENAI_API_KEY ✱ unset Enables the openai provider in the same layer, same terms.

Credential verification (OAuth)

Variable Default Notes
GITHUB_OAUTH_CLIENT_ID unset
GITHUB_OAUTH_CLIENT_SECRET unset
ORCID_OAUTH_CLIENT_ID unset
ORCID_OAUTH_CLIENT_SECRET unset
ORCID_API_BASE_URL ✱ https://orcid.org Point at the sandbox host for testing.

Unset providers render as unavailable in the UI rather than failing when clicked. LinkedIn, Google Scholar, Kaggle, X and Website are shown as unavailable by design — no verification is implemented.

Notification channels

Variable Default Notes
SLACK_CLIENT_ID unset Slack needs an OAuth app.
SLACK_CLIENT_SECRET unset
TELEGRAM_BOT_TOKEN unset
TELEGRAM_BOT_USERNAME unset

Discord, Google Chat and Microsoft Teams are webhook-based — the member pastes a webhook URL, so they need no server configuration. An unconfigured channel dead-letters with the real reason and is never recorded as sent.

Dataset publication

A published community dataset is pushed to every configured target. Both targets receive the same data/items.jsonl and manifest.json bytes — so the two publications are verifiably the same dataset — and differ only in the repository furniture each host reads. The public training split contains contributor submissions only: sponsor reference examples remain review evidence and are never exported as rows. If a legacy or concurrent close-out leaves more accepted submissions than the approved Bounty.targetItems, publication deterministically exports only the oldest targetItems rows and records an audit event for the cap.

Variable Default Notes
HUGGINGFACE_API_TOKEN unset Write token. Without it the Hugging Face target records not_configured.
HUGGINGFACE_NAMESPACE unset User or org datasets are created under.
HUGGINGFACE_API_URL https://huggingface.co/api Override for a self-hosted Hub.
GITHUB_PUBLICATION_TOKEN unset A dedicated credential, never the account token used by gh — contents:write scoped to just the one shared datasets repo below is enough.
GITHUB_PUBLICATION_OWNER unset Org or user that owns the shared datasets repo.
GITHUB_PUBLICATION_REPO databounty-datasets The one shared repo every dataset publishes into, as its own folder (datasets/<slug>/) — not one repo per dataset.
GITHUB_PUBLICATION_API_URL https://api.github.com Set for GitHub Enterprise.
GITHUB_PUBLICATION_BRANCH_PROTECTED unset (false) When true, publish and unpublish push through a branch + pull request + merge instead of committing straight to main: create a branch from the built commit, open a PR (head: that branch, base: main), merge it via the API, then best-effort delete the branch. Default/unset keeps today's direct-push-to-main behavior unchanged. Turn this on only after the shared databounty-datasets repo actually has branch protection requiring PRs on main — setting this var does not itself enable that protection, request any GitHub permissions, or change the repo's settings; that is a separate owner action.
PUBLICATION_REQUEST_TIMEOUT_MS 10000 Shared by both targets.
PUBLICATION_UPLOAD_TIMEOUT_MS 300000 Per-file upload timeout.
COMMUNITY_PUBLISH_PRIVATE unset (false) Off by default — production datasets are meant to be public. Set to true only in an environment whose GITHUB_PUBLICATION_REPO/HUGGINGFACE_NAMESPACE point at dev-only repos/orgs, so a test publish there never lands public.

What lands in each repo

File Hugging Face GitHub
data/items.jsonl ✅ ✅ identical bytes
manifest.json ✅ ✅ identical bytes
data/contributor-items/… ✅ ✅ identical bytes
README.md dataset card with the Hub's YAML front matter (license:, configs:, size_categories:) plain README — front matter would be meaningless noise on GitHub
LICENSE — (the Hub reads the front-matter license: tag) ✅ full verbatim licence text
.gitattributes — ✅ marks the JSONL generated, so it is collapsed in diffs and excluded from the language bar

LICENSE carries the full text, not a pointer, because GitHub identifies a repository's licence by matching that file against known texts — a repo naming its licence only in prose reads as all rights reserved, the opposite of what an open dataset intends. Texts are bundled for CC-BY-4.0, CC-BY-SA-4.0, CC0-1.0 and ODC-By-1.0 (the four a bounty can select). Any other licence value means LICENSE is omitted rather than guessed, and the README says the full text is not bundled.

Contributor uploads are published. A role: "file" field holds an artifact id; the bytes behind it are pushed to data/contributor-items/<submissionId>/<fieldKey>-<artifactId>-<filename>, and the item's field value in items.jsonl is rewritten to that path, so an item and the file it names always agree. Only ready artifacts of kind submission_attachment are eligible.

One publish run buffers each file in memory before pushing, so a cumulative 200 MB cap bounds the job. The cap is applied to the artifact's verified stored size before a single byte is read. Going over it is deliberately not fatal — a partial dataset with an accurate manifest beats blocking the whole publish — but it is never silent: manifest.json carries attachments: { resolved, published }, both READMEs print a Partial upload set notice, and an audit-log row (community_publication.attachments_truncated) records the counts and the cap. An item whose file was skipped keeps its raw artifact id rather than a path to a file that is not there.

A GitHub-specific limit. Git LFS is out of scope, so a single file at or over GitHub's 100 MB API limit fails that publish permanently, naming the file, rather than being dropped from the commit. The 200 MB cumulative cap does not prevent this — one 120 MB upload is under the cap but over GitHub's per-file ceiling, and would publish to Hugging Face and fail on GitHub. Hugging Face stays the primary target for datasets with large files.

Withdrawal (retract) never deletes. The admin retract action enqueues a community.unpublish job that calls each provider's own withdrawal: Hugging Face and GitHub are both flipped to private, so the commits and the contributor provenance in them survive, only public reachability is removed, and the retraction stays reversible. A 404 counts as success — the dataset is already unreachable, which is the state the call exists to guarantee.

A target is recorded retracted only after its provider confirms. If the withdrawal cannot be performed — no credential, no recorded repo id, a provider error — the row stays published, because the dataset really is still public, and the reason is written to lastError and to the audit log (community_publication.retract_failed). The bounty-level status becomes retracted only when every previously-published target actually withdrew; a partial withdrawal never displays as a complete one. Retract is not gated on the community.publish.enabled kill switch — turning publishing off must never prevent taking something down.

Rate limits

Per-instance, in-memory counters unless noted. Behind more than one API instance the effective limit is the value below multiplied by the instance count — size them accordingly, and set TRUST_PROXY or every request will share one bucket keyed on the load balancer's address.

Variable Default Notes
RATELIMIT_GLOBAL_MAX ✱ plugin default Global per-IP request ceiling, read in app.ts.
MCP_RATE_LIMIT_PER_MIN ✱ 300 Per-credential MCP calls per minute. Backed by the api_key_rate_buckets table, so this one is shared across instances (lib/mcp-rate-limit.ts).
API_KEY_RATE_LIMIT_PER_MIN ✱ 300 Fallback for the above when MCP_RATE_LIMIT_PER_MIN is unset.
MCP_TOKEN_RATE_LIMIT_PER_MIN ✱ 60 Token requests per minute per client_id on POST /mcp/oauth/token (lib/mcp-rate-limit.ts). That endpoint carries no Authorization header — it is a public PKCE client — so the per-credential limiter in app.ts never sees it, and the per-IP fallback is useless behind a proxy with TRUST_PROXY unset. 0 disables.
MCP_TOKEN_REPLAY_LIMIT_PER_MIN ✱ 5 Token requests per minute keyed on the presented authorization code or refresh token, hashed. Rotation gives a healthy client a new secret each time, so it never nears this; a client replaying one dead token is throttled within seconds. Refused if either this or the per-client_id bucket is exceeded, answered 429 with an RFC 8628 slow_down body. 0 disables.
RATE_BUCKET_PRUNE_INTERVAL_MS ✱ 300000 How often expired one-minute rate-limit buckets are deleted (pruneOldRateBuckets, 10-window grace). Previously never scheduled at all, so the table grew unbounded.
MCP_SESSION_SWEEP_INTERVAL_MS ✱ 60000 How often idle sessionful-MCP transports are closed when no request is arriving (routes/mcp.ts). Expiry is also checked on each request; this timer is what reaps a client that went silent. 0 disables the timer and restores request-driven-only sweeping.
MCP_MAX_SESSIONS_PER_CREDENTIAL ✱ 8 Concurrent sessionful-MCP sessions one API key or OAuth grant may hold. Oldest are closed first when a new handshake arrives. A client that loses its session id mints a new session per request, so this is the bound on that.
MCP_MAX_SESSIONS_TOTAL ✱ 500 Process-wide ceiling on live MCP sessions, evicted oldest-first. Backstop for many credentials each under the per-credential cap.

Background workers and sweeps

Read in worker.ts (and services/worker-heartbeat.ts, services/notifications.ts) rather than config.ts. All are milliseconds, all have working defaults, and none needs setting for a normal deployment — they exist to slow a sweep down on a small instance or speed one up while debugging. Every value is a tick interval, not a deadline: shortening one does not make the underlying window shorter, it only checks more often.

Variable Default Sweeps
WORKER_POLL_INTERVAL_MS ✱ 2000 Job-queue claim loop.
WORKER_TICK_TIMEOUT_MS ✱ — Per-tick watchdog budget; a tick exceeding it is reported unhealthy (services/worker-heartbeat.ts).
WATCHDOG_INTERVAL_MS ✱ 60000 Stuck-submission detection.
NOTIFICATIONS_DISPATCH_INTERVAL_MS ✱ 15000 Outbox dispatch.
NOTIFICATIONS_DISPATCH_LIMIT ✱ 250 Rows claimed per dispatch pass (services/notifications.ts).
NOTIFICATIONS_DISPATCH_CONCURRENCY ✱ 16 Parallel sends per pass (services/notifications.ts).
NOTIFICATIONS_DIGEST_INTERVAL_MS ✱ 300000 Digest flush.
AUDIT_REAPER_INTERVAL_MS ✱ 300000 Returns expired audit claims to the queue.
KARMA_HOLD_RELEASE_INTERVAL_MS ✱ 300000 Releases karma past its hold window.
POOL_RECONCILE_INTERVAL_MS 600000 Recounts open-pool capacity and settles pools closed after reaching their verified-item target. Community pools are open-ended; time does not close them.
ARTIFACT_SWEEP_INTERVAL_MS ✱ 300000 Expires abandoned pending uploads.
AGENT_ISSUE_SWEEP_INTERVAL_MS ✱ 300000 Agent-reported issue housekeeping.
LEADERBOARD_SWEEP_INTERVAL_MS ✱ 600000 Leaderboard rank/movement snapshot.
WAITLIST_SWEEP_INTERVAL_MS ✱ 600000 Domain-waitlist notification sweep.
PROFILE_SOURCE_SWEEP_INTERVAL_MS ✱ 600000 Expires stale credential-connect state tokens.
MCP_OAUTH_CLEANUP_INTERVAL_MS ✱ 300000 Deletes expired MCP OAuth grants/codes.
ADMIN_METRICS_SNAPSHOT_INTERVAL_MS ✱ 300000 Admin overview metric snapshot.

Public read cache

Read in lib/public-read-cache.ts. The two unauthenticated read routes the landing site renders from — GET /v1/community/catalog and GET /v1/community/stats — are cached in-process, per validated query, for this many milliseconds. The cached body never depends on the caller (no headers, cookies or session go into the key), errors are never cached, and concurrent misses on one key share one computation. Added 2026-09-22 after those two routes were measured as 85%+ of a steady ~10 GB/day of Supabase egress on both deployments, driven by the landing container's own health probe re-rendering the homepage every 15 seconds rather than by visitors.

Variable Default Effect
CACHE_DRIVER ✱ noop Which cache driver backs lib/cache/: noop, memory or redis. An invalid value throws at boot rather than silently leaving the cache off. noop means the application behaves exactly as it did before the layer existed — every read a miss, every write a no-op — which is what makes enabling it safe. redis builds an in-process L1 in front of Redis; with REDIS_URL empty it logs and falls back to noop.
REDIS_URL ✱ (empty) Connection string used when CACHE_DRIVER=redis. On the dev deployment this is redis://redis:6379 — an internal Docker hostname, not published, so no credentials. The driver fails open behind a circuit breaker: if Redis is unreachable the app degrades to the database path instead of waiting out timeouts. Every key this application writes is namespaced databounty:, so the instance may safely be shared with another application — as it is in production, where the Redis also serves a co-tenant on the same host. What is cached: public catalogue reads (invalidated explicitly on write), verified API keys (60s, dropped immediately on revoke, rotation and account suspension — a cached credential never outlives the decision that revoked it), and the per-credential request rate-limit counters. The token-endpoint rate limits deliberately stay in Postgres whatever this is set to: they are a security control, and a shared Redis can evict a key under memory pressure. If the cache is off or unreachable, every counter falls back to Postgres and the limits keep applying — they never fail open.
PUBLIC_READ_CACHE_TTL_MS ✱ 60000 (0 under NODE_ENV=test) Maximum age of a cached public catalog/stats body. 0 bypasses the cache; the integration suite relies on that so a write is visible on the next read. Nothing downstream expects fresher than this — the landing refreshes itself every two minutes.

Tooling only

Variable Default Notes
DATABOUNTY_API_KEY ✱ unset Read by the stdio MCP entrypoint (src/mcp/stdio.ts) to authenticate as a member. This is a client-side variable for whoever runs the stdio server — it is not part of the API server's own configuration, and setting it on the API process does nothing.

Part 2 — Frontend environment variables

The three Next.js apps read only public values. Anything named NEXT_PUBLIC_* is compiled into the browser bundle — never put a secret in one.

Variable Needed by Notes
NEXT_PUBLIC_API_URL web, admin, landing Where apps/api is.
NEXT_PUBLIC_DASHBOARD_URL web, admin, landing Cross-links, e.g. landing's "sign in".
NEXT_PUBLIC_ADMIN_URL web, admin
NEXT_PUBLIC_LANDING_URL web, admin, landing Also the host in public-profile URLs.
NEXT_OUTPUT any standalone for a self-contained server bundle; export (web and admin only) for a static out/.

Every origin you set here must also appear in the API's CORS_ORIGINS.


Part 3 — Runtime settings (admin_settings)

29 settings, each with a schema that rejects invalid values instead of accepting them silently, and a full change history. Edited in the admin console at /settings, read live — no redeploy.

Values are read on demand, so a change takes effect on the next check. Anything already in flight finishes under the value it started with.

Community pools and audit

Key What it controls
community.dispute_window_hours How long a contributor has to dispute a rejection before it is final.
community.karma_holds.enabled Whether accepted-item karma is held through the dispute window instead of released immediately.
community.human_audit_window_size Items per human-audit window — the batch a validator claims. Bounded 50–100.
community.human_audit_failure_threshold_pct Percent of a window's selected items that must be rejected for the whole window to fail.
community.min_karma_to_request Minimum karma before a member may file a dataset request. Your anti-spam dial.
community.leaderboard.page_size Default leaderboard page size.
community.leaderboard.max_page_size Hard ceiling on one leaderboard request.

Validation

Key What it controls
validation.dedupe.reject_threshold Duplicate score at or above which duplicate_check rejects.
validation.llm.enabled The LLM review stage platform-wide. Off by default — deterministic checks first.
submissions.max_revisions Revision attempts before final rejection.

Karma and reputation

Key What it controls
karma.tiers The tier ladder — id, label, karma threshold, accent colour, perks, early-access hours.
karma.rules Flat award rates: per accepted item by difficulty, per audited item, and so on.
karma.matrix Per-dataset-type karma rate inputs, keyed on registry id.
reputation.score.base Starting reputation score.
reputation.score.per_verified_credential Points per verified credential, up to a 100-point cap.

The karma tiers shipped in code are 0 / 5 / 50 / 500. A deployment that has written karma.tiers uses its stored value, which may differ by orders of magnitude. The code values are defaults, not the tiers — check the console before quoting a threshold to anyone.

Notifications

Key What it controls
notifications.digest.enabled Whether the periodic digest is sent at all.
notifications.digest.time Local HH:mm the daily digest is flushed.
notifications.digest.timezone IANA zone the time above is read in.
notifications.delivery.max_attempts Retries before a delivery is parked in the dead-letter state.
notifications.delivery.lease_seconds How long a dispatch worker holds an exclusive lease.
notifications.delivery.backoff_seconds Retry backoff ladder; the last entry repeats.

Feature switches

Key What it controls
launch.community.enabled Master switch for community karma activity.
launch.community_requests.enabled Authenticated dataset-request submission.
launch.dashboard_submissions.enabled Submitting items from the dashboard UI.
launch.api_submissions.enabled Submitting items via a direct API-key call.
launch.public_profiles.enabled Anonymous public-profile reads. Disabled returns the same not-found response as a missing profile.

Auth and limits

Key What it controls
auth.email_verification.ttl_hours How long a verification link stays valid.
ratelimit.global.max Requests per window before 429.
ratelimit.global.window_seconds Window length.

Some routes carry their own tighter limits on top of the global one — see api.md.