Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ PG_CONN='postgres://postgres:postgres@localhost:5432/archive' \
| `PORT` | `8080` | Port the GraphQL server listens on |
| `ENABLE_GRAPHIQL` | `false` | Serve the GraphiQL playground at `/` |
| `ENABLE_INTROSPECTION` | `false` | Allow GraphQL schema introspection |
| `ENABLE_LOGGING` | `false` | Enable request logging |
| `ENABLE_LOGGING` | `false` | Enable OpenTelemetry request tracing |
| `ENABLE_METRICS` | `false` | Expose Prometheus metrics at `/metrics` |
| `ENABLED_QUERIES` | _(all)_ | Comma-separated subset of root query fields |
| `ENABLE_JAEGER` | `false` | Emit traces to a Jaeger collector |
| `JAEGER_ENDPOINT` | — | e.g. `http://localhost:14268/api/traces` |

Expand Down
38 changes: 26 additions & 12 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,9 @@ JAEGER_ENDPOINT=http://localhost:14268/api/traces

## Configuration

The server reads config from environment variables. `PG_CONN` is the only required one.
The server reads config from environment variables. `PG_CONN` is the only required one. Configuration is validated at startup — a missing `PG_CONN`, a non-numeric `PORT`, a mistyped boolean, or an invalid `ENABLED_QUERIES` value makes the server exit immediately with a clear message rather than booting into a broken state.

Boolean variables (`ENABLE_*`) accept `true`/`false`, `1`/`0`, `yes`/`no`, or `on`/`off` (case-insensitive); `false` reliably means off. Any other non-empty spelling now aborts startup instead of being interpreted inconsistently. `ENABLE_LOGGING`, `ENABLE_INTROSPECTION`, and `ENABLE_JAEGER` previously treated any non-empty value as on; `ENABLE_GRAPHIQL` and `ENABLE_BLOCK_TRANSACTION_DETAILS` previously only accepted the literal string `true` as on.

| Variable | Default | Description |
| --- | --- | --- |
Expand All @@ -185,10 +187,11 @@ The server reads config from environment variables. `PG_CONN` is the only requir
| `TRUST_PROXY` | _(unset)_ | Number of trusted proxy hops in front of the API. Required when `RATE_LIMIT_MAX > 0`: the limiter stays disabled until it is set. `0` ignores `X-Forwarded-For` and keys on the socket address |
| `ENABLE_GRAPHIQL` | `false` | If `true`, serves the GraphiQL playground at `/` |
| `ENABLE_INTROSPECTION` | `false` | If `true`, allows GraphQL schema introspection |
| `ENABLE_LOGGING` | `false` | Enable request logging |
| `ENABLE_LOGGING` | `false` | Enable OpenTelemetry request tracing |
| `ENABLE_METRICS` | `false` | If `true`, exposes unauthenticated Prometheus metrics at `/metrics` |
| `BLOCK_RANGE_SIZE` | `10000` | Max block range a single query may span |
| `ENABLE_BLOCK_TRANSACTION_DETAILS` | `false` | Include `userCommands` / `zkappCommands` / `feeTransfers` |
| `ENABLED_QUERIES` | _(all)_ | Comma-separated subset of `events,actions,networkState,blocks` to expose; omitted fields are removed from the schema |
| `ENABLE_JAEGER` | `false` | Emit traces to a Jaeger collector |
| `JAEGER_SERVICE_NAME` | `archive-api` | Service name reported to Jaeger |
| `JAEGER_ENDPOINT` | — | e.g. `http://localhost:14268/api/traces` |
Expand Down Expand Up @@ -277,9 +280,20 @@ This query returns the latest indexed block height — compare it with [MinaScan
```graphql
query GetEvents {
events(input: { address: "B62..." }) {
blockInfo { height stateHash timestamp chainStatus }
eventData { data }
transactionInfo { status hash memo }
blockInfo {
height
stateHash
timestamp
chainStatus
}
eventData {
data
}
transactionInfo {
status
hash
memo
}
}
}
```
Expand All @@ -290,14 +304,14 @@ Replace `B62...` with the address of the zkApp whose events you want.

## Troubleshooting

| Symptom | Likely cause | Fix |
| --- | --- | --- |
| `An error occurred: AggregateError [ECONNREFUSED]` | `PG_CONN` host/port wrong or DB not running | Verify with `psql "$PG_CONN" -c 'SELECT 1'` |
| `relation "..." does not exist` on startup | Postgres reachable but not an archive-node schema | Point `PG_CONN` at an actual archive-node DB |
| `/` returns 404 in the browser | `ENABLE_GRAPHIQL` not set | Set `ENABLE_GRAPHIQL=true` and restart |
| `EADDRINUSE: address already in use :::8080` | Port already taken | `PORT=<free port>` and restart |
| Symptom | Likely cause | Fix |
| ---------------------------------------------------------- | ------------------------------------------------- | -------------------------------------------------------- |
| `An error occurred: AggregateError [ECONNREFUSED]` | `PG_CONN` host/port wrong or DB not running | Verify with `psql "$PG_CONN" -c 'SELECT 1'` |
| `relation "..." does not exist` on startup | Postgres reachable but not an archive-node schema | Point `PG_CONN` at an actual archive-node DB |
| `/` returns 404 in the browser | `ENABLE_GRAPHIQL` not set | Set `ENABLE_GRAPHIQL=true` and restart |
| `EADDRINUSE: address already in use :::8080` | Port already taken | `PORT=<free port>` and restart |
| Compose: API starts but logs `relation ... does not exist` | Snapshot still loading into Postgres on first run | Wait for the `postgres` container to finish initialising |
| Compose: snapshot download script fails | Network or storage limit | Re-run `./scripts/download_db.sh`; check disk space |
| Compose: snapshot download script fails | Network or storage limit | Re-run `./scripts/download_db.sh`; check disk space |

---

Expand Down
119 changes: 119 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
export { parseBoolean, validateConfig, assertValidConfig };

type EnvSource = Record<string, string | undefined>;

const TRUE_VALUES = new Set(['true', '1', 'yes', 'on']);
const FALSE_VALUES = new Set(['false', '0', 'no', 'off']);

/** Env vars interpreted as booleans. */
const BOOLEAN_VARS = [
'ENABLE_GRAPHIQL',
'ENABLE_INTROSPECTION',
'ENABLE_LOGGING',
'ENABLE_METRICS',
'ENABLE_JAEGER',
'ENABLE_BLOCK_TRANSACTION_DETAILS',
] as const;

/** Env vars that, when set, must be positive integers. */
const POSITIVE_INT_VARS = ['PORT', 'BLOCK_RANGE_SIZE'] as const;

/** Root query fields in schema.graphql — keep in sync. */
const KNOWN_QUERIES = ['events', 'actions', 'networkState', 'blocks'] as const;

/**
* Parse a boolean environment value. Recognises `true/false`, `1/0`, `yes/no`,
* `on/off` (case-insensitive). Anything unrecognised — including the empty
* string or `undefined` — yields `fallback`.
*
* This replaces ad-hoc truthiness checks like `if (process.env.ENABLE_X)`, which
* treated the string `"false"` as `true` (#74).
*/
function parseBoolean(value: string | undefined, fallback = false): boolean {
if (value === undefined) return fallback;
const normalized = value.trim().toLowerCase();
if (normalized === '') return fallback;
if (TRUE_VALUES.has(normalized)) return true;
if (FALSE_VALUES.has(normalized)) return false;
return fallback;
}

function isRecognisedBoolean(value: string): boolean {
const normalized = value.trim().toLowerCase();
return TRUE_VALUES.has(normalized) || FALSE_VALUES.has(normalized);
}

/**
* Validate the environment, returning a list of human-readable problems (empty
* when valid). Catches the common misconfigurations — a missing connection
* string, a non-numeric port, a mistyped boolean — so they surface at startup
* rather than as confusing runtime behaviour.
*/
function validateConfig(env: EnvSource = process.env): string[] {
const errors: string[] = [];

if (!env.PG_CONN || env.PG_CONN.trim() === '') {
errors.push('PG_CONN is required (Postgres connection string).');
}

for (const name of BOOLEAN_VARS) {
const value = env[name];
if (
value !== undefined &&
value.trim() !== '' &&
!isRecognisedBoolean(value)
) {
errors.push(`${name} must be a boolean (true/false), got "${value}".`);
}
}

for (const name of POSITIVE_INT_VARS) {
const value = env[name];
if (value !== undefined && value.trim() !== '') {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
errors.push(`${name} must be a positive integer, got "${value}".`);
}
}
}

const enabledQueries = env.ENABLED_QUERIES;
if (enabledQueries !== undefined) {
const names = enabledQueries
.split(',')
.map((query) => query.trim())
.filter((query) => query !== '');
if (names.length === 0) {
errors.push(
'ENABLED_QUERIES is set but lists no queries; unset it to expose all of ' +
`${KNOWN_QUERIES.join(', ')}.`
);
}

const unknown = names.filter(
(name) => !(KNOWN_QUERIES as readonly string[]).includes(name)
);
if (unknown.length > 0) {
errors.push(
`ENABLED_QUERIES contains unknown queries: ${unknown.join(', ')}. ` +
`Known queries: ${KNOWN_QUERIES.join(', ')}.`
);
}
}

return errors;
}

/**
* Validate the environment and throw a single aggregated error if anything is
* wrong, so the process fails fast at startup with a clear message instead of
* booting into a broken state.
*/
function assertValidConfig(env: EnvSource = process.env): void {
const errors = validateConfig(env);
if (errors.length > 0) {
throw new Error(
`Invalid configuration:\n${errors.map((e) => ` - ${e}`).join('\n')}`
);
}
}
2 changes: 2 additions & 0 deletions src/envionment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ declare global {
ENABLE_INTROSPECTION?: bool;
ENABLE_GRAPHIQL?: bool;
ENABLE_JAEGER?: bool;
ENABLE_BLOCK_TRANSACTION_DETAILS?: bool;
ENABLED_QUERIES?: string;
JAEGER_ENDPOINT?: string;
JAEGER_SERVICE_NAME?: string;
}
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { buildContext } from './context.js';
import { buildServer } from './server/server.js';
import { buildPlugins } from './server/plugins.js';
import { createGracefulShutdown } from './server/graceful-shutdown.js';
import { assertValidConfig } from './config.js';

const PORT = process.env.PORT || 8080;
const SHUTDOWN_TIMEOUT_MS = Number(process.env.SHUTDOWN_TIMEOUT_MS) || 20000;
Expand Down Expand Up @@ -31,6 +32,7 @@ function withTimeout(

(async function main() {
try {
assertValidConfig();
const context = await buildContext(process.env.PG_CONN);
const { plugins, provider } = await buildPlugins();
const server = buildServer(context, plugins);
Expand Down
7 changes: 4 additions & 3 deletions src/server/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { inspect } from 'node:util';

import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
import { initJaegerProvider } from '../tracing/jaeger-tracing.js';
import { parseBoolean } from '../config.js';
import { useMetrics } from './metrics.js';
import { useRateLimit } from './rate-limit.js';

Expand All @@ -19,7 +20,7 @@ async function buildPlugins() {
// so over-limit traffic is rejected as cheaply as possible.
plugins.push(useRateLimit());

if (process.env.ENABLE_METRICS === 'true') {
if (parseBoolean(process.env.ENABLE_METRICS)) {
// Prometheus /metrics endpoint + RED metrics for every request.
plugins.push(useMetrics());
}
Expand All @@ -28,7 +29,7 @@ async function buildPlugins() {

// Returned so the entry point can flush spans on shutdown.
let provider: BasicTracerProvider | undefined;
if (process.env.ENABLE_LOGGING) {
if (parseBoolean(process.env.ENABLE_LOGGING)) {
provider = await initJaegerProvider();
plugins.push(
useOpenTelemetry(
Expand All @@ -45,7 +46,7 @@ async function buildPlugins() {
);
}

if (!process.env.ENABLE_INTROSPECTION) {
if (!parseBoolean(process.env.ENABLE_INTROSPECTION)) {
plugins.push(useDisableIntrospection());
}

Expand Down
8 changes: 5 additions & 3 deletions src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createServer } from 'http';
import { Plugin } from '@envelop/core';
import { schema } from '../resolvers.js';
import type { GraphQLContext } from '../context.js';
import { parseBoolean } from '../config.js';
import { useReadiness } from './readiness.js';

export {
Expand All @@ -14,8 +15,9 @@ export {

const LOG_LEVEL = (process.env.LOG_LEVEL as LogLevel) || 'info';
const BLOCK_RANGE_SIZE = Number(process.env.BLOCK_RANGE_SIZE) || 10000;
const ENABLE_BLOCK_TRANSACTION_DETAILS =
process.env.ENABLE_BLOCK_TRANSACTION_DETAILS === 'true';
const ENABLE_BLOCK_TRANSACTION_DETAILS = parseBoolean(
process.env.ENABLE_BLOCK_TRANSACTION_DETAILS
);

function buildYoga(context: GraphQLContext, plugins: Plugin[]) {
return createYoga<GraphQLContext>({
Expand All @@ -25,7 +27,7 @@ function buildYoga(context: GraphQLContext, plugins: Plugin[]) {
landingPage: false,
// Liveness — the process is up and serving HTTP.
healthCheckEndpoint: '/healthcheck',
graphiql: process.env.ENABLE_GRAPHIQL === 'true' ? true : false,
graphiql: parseBoolean(process.env.ENABLE_GRAPHIQL),
// Mask unexpected (non-GraphQLError) errors so internal details — SQL,
// connection strings, stack traces — never reach clients. `isDev: false`
// keeps Envelop from attaching original errors when NODE_ENV=development.
Expand Down
3 changes: 2 additions & 1 deletion src/tracing/jaeger-tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
parseEndpoint,
checkJaegerEndpointAvailability,
} from './jaeger-setup.js';
import { parseBoolean } from '../config.js';

export { initJaegerProvider };

Expand All @@ -20,7 +21,7 @@ function createJaegerExporter(endpoint: string) {

async function initJaegerProvider(): Promise<BasicTracerProvider | undefined> {
const jaegerEndpoint = process.env.JAEGER_ENDPOINT;
if (!process.env.ENABLE_JAEGER || !jaegerEndpoint) {
if (!parseBoolean(process.env.ENABLE_JAEGER) || !jaegerEndpoint) {
return undefined;
}

Expand Down
Loading
Loading