Skip to content
Draft
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
21 changes: 20 additions & 1 deletion graphql/env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ In addition to all environment variables supported by `@pgpmjs/env`, this packag

### GraphQL Schema
- `GRAPHILE_SCHEMA` - Comma-separated list of PostgreSQL schemas to expose
- `GRAPHILE_INTROSPECTION_CLIENT_RELEASE_MODE` - `reuse` preserves the introspection backend; `destroy` retires that exact client after gather and reconnects lazily for runtime traffic; defaults to `reuse`
- `GRAPHILE_REALTIME_SCHEMA` - Exact physical schema containing realtime cursor functions; omission preserves `realtime_public`
- `GRAPHILE_REALTIME_NOTIFICATION_MODE` - `dedicated` keeps one PostGraphile subscriber per instance; `shared-exact` opts into the per-database exact-topic broker and requires an application `notificationPgResolver`; defaults to `dedicated`
- `GRAPHILE_REALTIME_NOTIFICATION_ROLE_REVALIDATION_MS` - Maximum age of a successful shared-listener role audit; defaults to `60000`
- `GRAPHILE_REALTIME_CURSOR_POLL_INTERVAL_MS` - Realtime cursor recovery poll interval; defaults to `5000`
- `GRAPHILE_REALTIME_CURSOR_HEARTBEAT_INTERVAL_MS` - Realtime cursor heartbeat interval; defaults to `30000`
- `GRAPHILE_RELEASE_BUILD_STATE_AFTER_VALIDATION` - Opt in to releasing schema-construction-only Graphile state after successful validation; defaults to `false`

### Feature Flags
- `FEATURES_SIMPLE_INFLECTION` - Enable simple inflection plugin
Expand All @@ -54,8 +61,18 @@ In addition to all environment variables supported by `@pgpmjs/env`, this packag
- `API_IS_PUBLIC` - Whether API is public
- `API_EXPOSED_SCHEMAS` - Comma-separated list of exposed schemas
- `API_META_SCHEMAS` - Comma-separated list of meta schemas
- `API_ALLOW_META_SCHEMA_HEADER` - Explicitly enable the privileged `X-Meta-Schema` control-plane surface. Defaults to false and must only be used on a separate private admin ingress.
- `API_ANON_ROLE` - Anonymous role name
- `API_ROLE_NAME` - Default role name
- `GRAPHQL_INTERNAL_REQUEST_SECRET` - Minimum-32-byte token required before private routing/actor headers or the HTTP cache flush endpoint are trusted. `X-Schemata` remains prohibited; use an authoritative API name.

### Routing Metadata Cache
- `GRAPHQL_ROUTING_CACHE_MAX_ENTRIES` - Capacity reserved for routing metadata diagnostics. Security-sensitive request routing is resolved authoritatively and never served from this cache.

### Runtime PostgreSQL credentials

- `GRAPHQL_RUNTIME_PGUSER` and `GRAPHQL_RUNTIME_PGPASSWORD` populate the legacy static `runtimePg` login.
- Production and `GRAPHILE_INTROSPECTION_MODE=scoped-required` do not accept those two values as a dynamic multi-tenant credential source. Use a programmatic `runtimePgResolver`; for a dedicated one-route server, pair an explicit static database with `runtimePgStaticIdentity` in trusted configuration.

## Defaults

Expand All @@ -75,8 +92,10 @@ GraphQL defaults are provided by `@constructive-io/graphql-types`:
roleName: 'administrator',
isPublic: true,
metaSchemas: ['routing_public', 'metaschema_public', 'metaschema_modules_public'],
allowMetaSchemaHeader: false,
routingSchema: 'routing_public'
}
},
routingCache: {}
}
```

Expand Down
11 changes: 11 additions & 0 deletions graphql/env/__tests__/__snapshots__/merge.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and overrides 1`] = `
{
"api": {
"allowMetaSchemaHeader": false,
"anonRole": "env_anon",
"exposedSchemas": [
"public",
Expand Down Expand Up @@ -70,10 +71,19 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and
},
"graphile": {
"extends": [],
"introspectionClientReleaseMode": "reuse",
"introspectionDependencySchemas": [],
"introspectionMode": "stock",
"preset": {},
"realtimeCursorHeartbeatIntervalMs": 30000,
"realtimeCursorPollIntervalMs": 5000,
"realtimeNotificationMode": "dedicated",
"realtimeNotificationRoleRevalidationMs": 60000,
"releaseBuildStateAfterValidation": false,
"schema": [
"override_schema",
],
"trustCallerPresetsInProduction": false,
},
"migrations": {
"codegen": {
Expand All @@ -87,6 +97,7 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and
"port": 5432,
"user": "env-user",
},
"routingCache": {},
"server": {
"host": "localhost",
"port": 5000,
Expand Down
40 changes: 40 additions & 0 deletions graphql/env/__tests__/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,46 @@ describe('getEnvOptions', () => {
expect(result.api?.metaSchemas).toEqual(['env_meta', 'override_meta']);
});

it('parses the internal request secret without exposing a default', () => {
const secret = '0123456789abcdef0123456789abcdef';

expect(getGraphQLEnvVars({ GRAPHQL_INTERNAL_REQUEST_SECRET: secret }).api)
.toMatchObject({ internalRequestSecret: secret });
expect(getGraphQLEnvVars({}).api?.internalRequestSecret).toBeUndefined();
});

it('keeps the privileged metadata header disabled unless explicitly configured', () => {
expect(getGraphQLEnvVars({ API_ALLOW_META_SCHEMA_HEADER: 'true' }).api)
.toMatchObject({ allowMetaSchemaHeader: true });
expect(getGraphQLEnvVars({ API_ALLOW_META_SCHEMA_HEADER: 'false' }).api)
.toMatchObject({ allowMetaSchemaHeader: false });
expect(getGraphQLEnvVars({}).api?.allowMetaSchemaHeader).toBeUndefined();
});

it('preserves the exact static runtime route contract from trusted config', () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graphql-env-runtime-pg-'));
const identity = {
databaseId: 'database-a',
databaseName: 'tenant_a',
apiId: 'api-a',
schemas: ['tenant_a_public', 'tenant_a_auth'],
roles: ['tenant_a_anon', 'tenant_a_user']
};
writeConfig(tempDir, {
runtimePg: {
database: 'tenant_a',
user: 'tenant_a_runtime',
password: 'runtime-secret'
},
runtimePgStaticIdentity: identity
});

const result = getEnvOptions({}, tempDir, {});

expect(result.runtimePgStaticIdentity).toEqual(identity);
expect(result.runtimePg?.database).toBe('tenant_a');
});

it('parses SMS environment variables into typed options', () => {
const result = getGraphQLEnvVars({
SMS_PROVIDER: 'devsms',
Expand Down
172 changes: 172 additions & 0 deletions graphql/env/src/__tests__/runtime-pg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { getGraphQLEnvVars } from '../env';

describe('GraphQL runtime PostgreSQL environment', () => {
it('maps the dedicated runtime credentials without changing control-plane pg', () => {
const result = getGraphQLEnvVars({
GRAPHQL_RUNTIME_PGUSER: 'graphql_runtime',
GRAPHQL_RUNTIME_PGPASSWORD: 'runtime-secret'
});

expect(result.runtimePg).toEqual({
user: 'graphql_runtime',
password: 'runtime-secret'
});
expect(result.pg).toBeUndefined();
});

it('does not create a runtime override when both variables are absent', () => {
expect(getGraphQLEnvVars({}).runtimePg).toBeUndefined();
});
});

describe('Graphile introspection environment', () => {
it.each(['stock', 'scoped-required'] as const)(
'accepts the explicit %s mode',
(introspectionMode) => {
expect(
getGraphQLEnvVars({ GRAPHILE_INTROSPECTION_MODE: introspectionMode }).graphile
).toEqual({ introspectionMode });
}
);

it('rejects unknown modes instead of falling back to stock', () => {
expect(() =>
getGraphQLEnvVars({ GRAPHILE_INTROSPECTION_MODE: 'scoped-if-possible' })
).toThrow("GRAPHILE_INTROSPECTION_MODE must be 'stock' or 'scoped-required'");
});

it.each(['reuse', 'destroy'] as const)(
'accepts the explicit %s introspection-client release mode',
(introspectionClientReleaseMode) => {
expect(getGraphQLEnvVars({
GRAPHILE_INTROSPECTION_CLIENT_RELEASE_MODE: introspectionClientReleaseMode
}).graphile).toEqual({ introspectionClientReleaseMode });
}
);

it('rejects an unknown introspection-client release mode', () => {
expect(() => getGraphQLEnvVars({
GRAPHILE_INTROSPECTION_CLIENT_RELEASE_MODE: 'best-effort'
})).toThrow(
"GRAPHILE_INTROSPECTION_CLIENT_RELEASE_MODE must be 'reuse' or 'destroy'"
);
});

it('parses the ordered dependency-schema allowlist without duplicates', () => {
expect(getGraphQLEnvVars({
GRAPHILE_INTROSPECTION_DEPENDENCY_SCHEMAS: 'extensions, shared_api,extensions'
}).graphile).toEqual({
introspectionDependencySchemas: ['extensions', 'shared_api']
});
});

it('rejects an empty dependency-schema entry', () => {
expect(() => getGraphQLEnvVars({
GRAPHILE_INTROSPECTION_DEPENDENCY_SCHEMAS: 'extensions, ,shared_api'
})).toThrow('must be a comma-separated list of non-empty schema names');
});
});

describe('Graphile realtime environment', () => {
it.each(['dedicated', 'shared-exact'] as const)(
'maps the explicit %s notification mode',
(realtimeNotificationMode) => {
expect(getGraphQLEnvVars({
GRAPHILE_REALTIME_NOTIFICATION_MODE: realtimeNotificationMode
}).graphile).toEqual({ realtimeNotificationMode });
}
);

it('rejects unknown notification modes', () => {
expect(() => getGraphQLEnvVars({
GRAPHILE_REALTIME_NOTIFICATION_MODE: 'shared-prefix'
})).toThrow("must be 'dedicated' or 'shared-exact'");
});

it('maps role revalidation and cursor timing intervals', () => {
expect(getGraphQLEnvVars({
GRAPHILE_REALTIME_NOTIFICATION_ROLE_REVALIDATION_MS: '60000',
GRAPHILE_REALTIME_CURSOR_POLL_INTERVAL_MS: '30000',
GRAPHILE_REALTIME_CURSOR_HEARTBEAT_INTERVAL_MS: '90000'
}).graphile).toEqual({
realtimeNotificationRoleRevalidationMs: 60_000,
realtimeCursorPollIntervalMs: 30_000,
realtimeCursorHeartbeatIntervalMs: 90_000
});
});

it('maps one exact cursor-function schema', () => {
expect(getGraphQLEnvVars({
GRAPHILE_REALTIME_SCHEMA: ' tenant_a_realtime '
}).graphile).toEqual({
realtimeSchema: 'tenant_a_realtime'
});
});

it('rejects a whitespace-only cursor schema', () => {
expect(() => getGraphQLEnvVars({
GRAPHILE_REALTIME_SCHEMA: ' '
})).toThrow('GRAPHILE_REALTIME_SCHEMA must be one non-empty exact schema name');
});

it('preserves the compatibility default by omitting absent configuration', () => {
expect(getGraphQLEnvVars({}).graphile?.realtimeSchema).toBeUndefined();
});
});

describe('Grafast cache-limit environment', () => {
it('maps all three schema-local cache bounds', () => {
expect(getGraphQLEnvVars({
GRAPHILE_QUERY_CACHE_MAX_LENGTH: '64',
GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH: '32',
GRAPHILE_OPERATION_PLANS_CACHE_MAX_LENGTH: '8'
}).graphile?.grafastCache).toEqual({
queryCacheMaxLength: 64,
operationsCacheMaxLength: 32,
operationOperationPlansCacheMaxLength: 8
});
});

it.each(['0', '-1', '1.5', '12entries'])(
'rejects an invalid cache bound %s',
(value) => {
expect(() => getGraphQLEnvVars({
GRAPHILE_OPERATIONS_CACHE_MAX_LENGTH: value
})).toThrow('must be a positive safe integer');
}
);
});

describe('Graphile build-state retirement environment', () => {
it.each([
['true', true],
['false', false]
])('maps the explicit %s value', (value, expected) => {
expect(getGraphQLEnvVars({
GRAPHILE_RELEASE_BUILD_STATE_AFTER_VALIDATION: value
}).graphile?.releaseBuildStateAfterValidation).toBe(expected);
});

it('keeps retirement absent unless explicitly configured', () => {
expect(
getGraphQLEnvVars({}).graphile?.releaseBuildStateAfterValidation
).toBeUndefined();
});
});

describe('Routing metadata cache environment', () => {
it('maps the explicit process capacity', () => {
expect(getGraphQLEnvVars({
GRAPHQL_ROUTING_CACHE_MAX_ENTRIES: '4096'
}).routingCache).toEqual({ maxEntries: 4096 });
});

it.each(['0', '-1', '1.5', '12entries'])(
'rejects an invalid routing cache capacity %s',
(value) => {
expect(() => getGraphQLEnvVars({
GRAPHQL_ROUTING_CACHE_MAX_ENTRIES: value
})).toThrow('must be a positive safe integer');
}
);
});
Loading