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
12 changes: 12 additions & 0 deletions graphql/env/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ In addition to all environment variables supported by `@pgpmjs/env`, this packag
- `API_ANON_ROLE` - Anonymous role name
- `API_ROLE_NAME` - Default role name

### OAuth Server
- `OAUTH_ENABLED` - Explicitly enable the unified-auth Provider flow (default: `false`)
- `OAUTH_PROVIDER_REQUEST_TIMEOUT_MS` - Per-request Provider timeout in milliseconds (default: `10000`, maximum: `60000`)

Provider endpoints, client IDs, secrets, scopes, and policy are Tenant data;
they are not process environment variables. Explicit malformed OAuth values
fail during option resolution instead of falling back silently.

## Defaults

GraphQL defaults are provided by `@constructive-io/graphql-types`:
Expand All @@ -76,6 +84,10 @@ GraphQL defaults are provided by `@constructive-io/graphql-types`:
isPublic: true,
metaSchemas: ['routing_public', 'metaschema_public', 'metaschema_modules_public'],
routingSchema: 'routing_public'
},
oauth: {
enabled: false,
providerRequestTimeoutMs: 10000
}
}
```
Expand Down
4 changes: 4 additions & 0 deletions graphql/env/__tests__/__snapshots__/merge.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and
"useTx": false,
},
},
"oauth": {
"enabled": false,
"providerRequestTimeoutMs": 10000,
},
"pg": {
"database": "config-db",
"host": "override-host",
Expand Down
103 changes: 103 additions & 0 deletions graphql/env/__tests__/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as path from 'path';

import { getGraphQLEnvVars } from '../src/env';
import { getEnvOptions } from '../src/merge';
import { OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS } from '../src/oauth';

const writeConfig = (dir: string, config: Record<string, unknown>): void => {
fs.writeFileSync(path.join(dir, 'pgpm.json'), JSON.stringify(config, null, 2));
Expand Down Expand Up @@ -230,6 +231,108 @@ describe('getEnvOptions', () => {
expect(result.sms).toBeUndefined();
});

it('defaults OAuth off with a ten-second Provider timeout', () => {
expect(getEnvOptions({}, process.cwd(), {}).oauth).toEqual({
enabled: false,
providerRequestTimeoutMs: 10_000
});
});

it('keeps absent OAuth environment variables out of partial overrides', () => {
expect(getGraphQLEnvVars({})).not.toHaveProperty('oauth');
});

it('parses explicit OAuth environment overrides', () => {
expect(
getGraphQLEnvVars({
OAUTH_ENABLED: 'true',
OAUTH_PROVIDER_REQUEST_TIMEOUT_MS: '2500'
}).oauth
).toEqual({
enabled: true,
providerRequestTimeoutMs: 2500
});
});

it('preserves config OAuth enablement when environment overrides are absent', () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graphql-env-oauth-'));
writeConfig(tempDir, {
oauth: {
enabled: true,
providerRequestTimeoutMs: 8000
}
});

expect(getEnvOptions({}, tempDir, {}).oauth).toEqual({
enabled: true,
providerRequestTimeoutMs: 8000
});
});

it('honors config, env, and runtime priority for OAuth', () => {
tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'graphql-env-oauth-priority-')
);
writeConfig(tempDir, {
oauth: {
enabled: false,
providerRequestTimeoutMs: 5000
}
});

const result = getEnvOptions(
{ oauth: { providerRequestTimeoutMs: 9000 } },
tempDir,
{ OAUTH_ENABLED: 'true', OAUTH_PROVIDER_REQUEST_TIMEOUT_MS: '7000' }
);

expect(result.oauth).toEqual({
enabled: true,
providerRequestTimeoutMs: 9000
});
});

it.each(['not-a-boolean', '', 'enabled'])(
'rejects an explicitly malformed OAuth enabled value %p',
value => {
expect(() => getGraphQLEnvVars({ OAUTH_ENABLED: value })).toThrow(
/OAUTH_ENABLED/
);
}
);

it.each([
'not-a-number',
'0',
'-1',
'1.5',
String(OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS + 1)
])('rejects an invalid OAuth Provider timeout %p', value => {
expect(() =>
getEnvOptions({}, process.cwd(), {
OAUTH_PROVIDER_REQUEST_TIMEOUT_MS: value
})
).toThrow(/providerRequestTimeoutMs|OAUTH_PROVIDER_REQUEST_TIMEOUT_MS/);
});

it('rejects invalid OAuth config and runtime override types after merging', () => {
tempDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'graphql-env-oauth-invalid-')
);
writeConfig(tempDir, { oauth: { enabled: 'yes' } });

expect(() => getEnvOptions({}, tempDir, {})).toThrow(
/oauth.enabled must be a boolean/
);
expect(() =>
getEnvOptions(
{ oauth: { providerRequestTimeoutMs: 60_001 } },
process.cwd(),
{}
)
).toThrow(/providerRequestTimeoutMs/);
});

it('omits an invalid SMS timeout from partial env overrides', () => {
const result = getGraphQLEnvVars({
SMS_REQUEST_TIMEOUT_MS: '5s'
Expand Down
4 changes: 4 additions & 0 deletions graphql/env/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ConstructiveOptions } from '@constructive-io/graphql-types';
import { parseEnvBoolean, parseEnvNumber } from '12factor-env';

import { getOAuthEnvVars } from './oauth';

/**
* @param env - Environment object to read from (defaults to process.env for backwards compatibility)
*/
Expand Down Expand Up @@ -38,6 +40,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial
// let an absent env var overwrite pgpm.json or consumer-specific values.
const smsRequestTimeoutMs = parseEnvNumber(SMS_REQUEST_TIMEOUT_MS);
const smsDryRun = parseEnvBoolean(SEND_SMS_DRY_RUN);
const oauth = getOAuthEnvVars(env);
const hasSmsEnvOverrides = Boolean(
SMS_PROVIDER ||
SMS_SENDER_ID ||
Expand Down Expand Up @@ -67,6 +70,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial
...(API_ANON_ROLE && { anonRole: API_ANON_ROLE }),
...(API_ROLE_NAME && { roleName: API_ROLE_NAME })
},
...(oauth && { oauth }),
...((EMBEDDER_PROVIDER || CHAT_PROVIDER) && {
llm: {
...((EMBEDDER_PROVIDER || EMBEDDER_MODEL || EMBEDDER_BASE_URL) && {
Expand Down
5 changes: 5 additions & 0 deletions graphql/env/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
// Export Constructive-specific env functions
export { getGraphQLEnvVars } from './env';
export { getConstructiveEnvOptions,getEnvOptions } from './merge';
export {
getOAuthEnvVars,
OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS,
validateOAuthServerOptions
} from './oauth';
export type { DevSmsOptions, SmsOptions } from '@constructive-io/graphql-types';
9 changes: 8 additions & 1 deletion graphql/env/src/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { getEnvOptions as getPgpmEnvOptions, loadConfigSync, replaceArrays } fro
import deepmerge from 'deepmerge';

import { getGraphQLEnvVars } from './env';
import { validateOAuthServerOptions } from './oauth';

/**
* Get Constructive environment options by merging:
Expand Down Expand Up @@ -36,21 +37,27 @@ export const getEnvOptions = (
const configOptions = loadConfigSync(cwd) as Partial<ConstructiveOptions>;

// Merge in order: core -> graphql defaults -> config (for graphql keys) -> graphql env -> overrides
return deepmerge.all([
const merged = deepmerge.all([
coreOptions,
constructiveGraphqlDefaults,
// Only merge graphql-related keys from config (if present)
{
...(configOptions.graphile && { graphile: configOptions.graphile }),
...(configOptions.features && { features: configOptions.features }),
...(configOptions.api && { api: configOptions.api }),
...(configOptions.oauth && { oauth: configOptions.oauth }),
...(configOptions.sms && { sms: configOptions.sms }),
},
graphqlEnvOptions,
overrides
], {
arrayMerge: replaceArrays
}) as ConstructiveOptions;

return {
...merged,
oauth: validateOAuthServerOptions(merged.oauth)
};
};

/**
Expand Down
74 changes: 74 additions & 0 deletions graphql/env/src/oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {
oauthServerDefaults,
type OAuthServerOptions
} from '@constructive-io/graphql-types';
import { bool, env as validateEnv, EnvError, num } from '12factor-env';

export const OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS = 60_000;

const assertProviderRequestTimeout = (value: unknown): number => {
if (
typeof value !== 'number' ||
!Number.isInteger(value) ||
value <= 0 ||
value > OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS
) {
throw new EnvError(
`oauth.providerRequestTimeoutMs must be an integer between 1 and ${OAUTH_PROVIDER_REQUEST_TIMEOUT_MAX_MS}`
);
}
return value;
};

/** Parse only explicitly supplied OAuth environment overrides. */
export const getOAuthEnvVars = (
input: NodeJS.ProcessEnv
): OAuthServerOptions | undefined => {
const overrides: OAuthServerOptions = {};
let configured = false;

if (input.OAUTH_ENABLED !== undefined) {
const parsed = validateEnv(
{ OAUTH_ENABLED: input.OAUTH_ENABLED },
{},
{ OAUTH_ENABLED: bool() }
);
overrides.enabled = parsed.OAUTH_ENABLED;
configured = true;
}

if (input.OAUTH_PROVIDER_REQUEST_TIMEOUT_MS !== undefined) {
const parsed = validateEnv(
{
OAUTH_PROVIDER_REQUEST_TIMEOUT_MS:
input.OAUTH_PROVIDER_REQUEST_TIMEOUT_MS
},
{},
{ OAUTH_PROVIDER_REQUEST_TIMEOUT_MS: num() }
);
overrides.providerRequestTimeoutMs = assertProviderRequestTimeout(
parsed.OAUTH_PROVIDER_REQUEST_TIMEOUT_MS
);
configured = true;
}

return configured ? overrides : undefined;
};

/** Validate and complete the effective OAuth options after all merge layers. */
export const validateOAuthServerOptions = (
input: OAuthServerOptions | undefined
): Required<OAuthServerOptions> => {
const enabled = input?.enabled ?? oauthServerDefaults.enabled;
if (typeof enabled !== 'boolean') {
throw new EnvError('oauth.enabled must be a boolean');
}

return {
enabled,
providerRequestTimeoutMs: assertProviderRequestTimeout(
input?.providerRequestTimeoutMs ??
oauthServerDefaults.providerRequestTimeoutMs
)
};
};
10 changes: 10 additions & 0 deletions graphql/types/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ const config: ConstructiveOptions = {
simpleInflection: true,
postgis: true,
},
oauth: {
enabled: false,
providerRequestTimeoutMs: 10_000,
},
};
```

Expand All @@ -68,6 +72,12 @@ Configuration for the Constructive API including meta API settings, exposed sche

Feature flags for GraphQL/Graphile including inflection settings and PostGIS support.

### OAuthServerOptions

GraphQL-server-owned OAuth enablement and bounded Provider request timeout.
Provider credentials and endpoint configuration remain Tenant data and are not
part of this type.

## Re-exports

This package re-exports all types from `@pgpmjs/types` for convenience, so you can import both core PGPM types and GraphQL types from a single package.
8 changes: 7 additions & 1 deletion graphql/types/src/constructive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
GraphileFeatureOptions,
GraphileOptions} from './graphile';
import { LlmOptions } from './llm';
import { oauthServerDefaults, type OAuthServerOptions } from './oauth';
import { SmsOptions } from './sms';

/**
Expand All @@ -29,6 +30,8 @@ export interface ConstructiveGraphQLOptions {
features?: GraphileFeatureOptions;
/** API configuration options */
api?: ApiOptions;
/** GraphQL server OAuth feature and transport options */
oauth?: OAuthServerOptions;
}

/**
Expand Down Expand Up @@ -58,6 +61,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt
llm?: LlmOptions;
/** SMS provider configuration */
sms?: SmsOptions;
/** GraphQL server OAuth feature and transport options */
oauth?: OAuthServerOptions;
}

/**
Expand All @@ -66,7 +71,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt
export const constructiveGraphqlDefaults: ConstructiveGraphQLOptions = {
graphile: graphileDefaults,
features: graphileFeatureDefaults,
api: apiDefaults
api: apiDefaults,
oauth: oauthServerDefaults
};

/**
Expand Down
5 changes: 5 additions & 0 deletions graphql/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export {
LlmEmbedderOptions,
LlmOptions} from './llm';

// Export GraphQL-server OAuth options
export {
oauthServerDefaults,
type OAuthServerOptions} from './oauth';

// Export SMS types
export {
DevSmsOptions,
Expand Down
17 changes: 17 additions & 0 deletions graphql/types/src/oauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* GraphQL-server-owned OAuth runtime options.
*
* Provider endpoints, credentials, scopes, and policy remain Tenant data and
* are deliberately absent from this process-level configuration surface.
*/
export interface OAuthServerOptions {
/** Explicitly enables the unified-auth Provider flow. */
enabled?: boolean;
/** Maximum duration of one outbound Provider HTTP request. */
providerRequestTimeoutMs?: number;
}

export const oauthServerDefaults: Required<OAuthServerOptions> = {
enabled: false,
providerRequestTimeoutMs: 10_000
};
Loading