diff --git a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap index 3e27125d0..531cd80ff 100644 --- a/graphql/env/__tests__/__snapshots__/merge.test.ts.snap +++ b/graphql/env/__tests__/__snapshots__/merge.test.ts.snap @@ -120,6 +120,15 @@ exports[`getEnvOptions merges pgpm defaults, graphql defaults, config, env, and "strictAuth": false, "trustProxy": false, }, + "sms": { + "devsms": { + "baseUrl": "http://env-devsms:4000", + }, + "dryRun": true, + "provider": "devsms", + "requestTimeoutMs": 9000, + "senderId": "OverrideSender", + }, "smtp": { "debug": false, "logger": false, diff --git a/graphql/env/__tests__/merge.test.ts b/graphql/env/__tests__/merge.test.ts index edbc3f6df..2f389c3e1 100644 --- a/graphql/env/__tests__/merge.test.ts +++ b/graphql/env/__tests__/merge.test.ts @@ -1,10 +1,14 @@ import { getEnvOptions } from '../src/merge'; +import { getGraphQLEnvVars } from '../src/env'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; const writeConfig = (dir: string, config: Record): void => { - fs.writeFileSync(path.join(dir, 'pgpm.json'), JSON.stringify(config, null, 2)); + fs.writeFileSync( + path.join(dir, 'pgpm.json'), + JSON.stringify(config, null, 2) + ); }; describe('getEnvOptions', () => { @@ -22,22 +26,31 @@ describe('getEnvOptions', () => { writeConfig(tempDir, { pg: { host: 'config-host', - database: 'config-db' + database: 'config-db', }, server: { - port: 4000 + port: 4000, }, graphile: { - schema: ['config_schema'] + schema: ['config_schema'], }, features: { - simpleInflection: false + simpleInflection: false, }, api: { enableServicesApi: false, isPublic: false, - metaSchemas: ['config_meta'] - } + metaSchemas: ['config_meta'], + }, + sms: { + provider: 'devsms', + senderId: 'ConfigSender', + requestTimeoutMs: 3000, + dryRun: false, + devsms: { + baseUrl: 'http://config-devsms:4000', + }, + }, }); const testEnv: NodeJS.ProcessEnv = { @@ -52,30 +65,39 @@ describe('getEnvOptions', () => { API_META_SCHEMAS: 'env_meta1,env_meta2', API_ANON_ROLE: 'env_anon', API_ROLE_NAME: 'env_role', - API_DEFAULT_DATABASE_ID: 'env_db' + API_DEFAULT_DATABASE_ID: 'env_db', + SMS_PROVIDER: 'devsms', + SMS_SENDER_ID: 'EnvSender', + SMS_REQUEST_TIMEOUT_MS: '4000', + SEND_SMS_DRY_RUN: 'true', + DEVSMS_BASE_URL: 'http://env-devsms:4000', }; const result = getEnvOptions( { db: { - cwd: '' + cwd: '', }, pg: { - host: 'override-host' + host: 'override-host', }, server: { - port: 5000 + port: 5000, }, graphile: { - schema: ['override_schema'] + schema: ['override_schema'], }, features: { - oppositeBaseNames: false + oppositeBaseNames: false, }, api: { enableServicesApi: false, - defaultDatabaseId: 'override_db' - } + defaultDatabaseId: 'override_db', + }, + sms: { + senderId: 'OverrideSender', + requestTimeoutMs: 9000, + }, }, tempDir, testEnv @@ -88,37 +110,164 @@ describe('getEnvOptions', () => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graphql-env-replace-')); writeConfig(tempDir, { graphile: { - schema: ['config_schema', 'shared_schema'] + schema: ['config_schema', 'shared_schema'], }, api: { exposedSchemas: ['public', 'shared'], - metaSchemas: ['metaschema_public', 'services_public', 'config_meta'] - } + metaSchemas: ['metaschema_public', 'services_public', 'config_meta'], + }, }); const testEnv: NodeJS.ProcessEnv = { GRAPHILE_SCHEMA: 'shared_schema,env_schema', API_EXPOSED_SCHEMAS: 'shared,env_schema', - API_META_SCHEMAS: 'services_public,env_meta' + API_META_SCHEMAS: 'services_public,env_meta', }; const result = getEnvOptions( { graphile: { - schema: ['override_schema', 'shared_schema'] + schema: ['override_schema', 'shared_schema'], }, api: { exposedSchemas: ['public', 'override_schema'], - metaSchemas: ['env_meta', 'override_meta'] - } + metaSchemas: ['env_meta', 'override_meta'], + }, }, tempDir, testEnv ); // Arrays are replaced, not merged - overrides win completely - expect(result.graphile?.schema).toEqual(['override_schema', 'shared_schema']); + expect(result.graphile?.schema).toEqual([ + 'override_schema', + 'shared_schema', + ]); expect(result.api?.exposedSchemas).toEqual(['public', 'override_schema']); expect(result.api?.metaSchemas).toEqual(['env_meta', 'override_meta']); }); + + it('parses SMS environment variables into typed options', () => { + const result = getGraphQLEnvVars({ + SMS_PROVIDER: 'devsms', + SMS_SENDER_ID: 'LocalSender', + SMS_REQUEST_TIMEOUT_MS: '2500', + SEND_SMS_DRY_RUN: 'true', + DEVSMS_BASE_URL: 'http://localhost:4000', + }); + + expect(result.sms).toEqual({ + provider: 'devsms', + senderId: 'LocalSender', + requestTimeoutMs: 2500, + dryRun: true, + devsms: { + baseUrl: 'http://localhost:4000', + }, + }); + }); + + it('accepts custom SMS provider names', () => { + const result = getGraphQLEnvVars({ + SMS_PROVIDER: 'custom-sms-gateway', + }); + + expect(result.sms?.provider).toBe('custom-sms-gateway'); + }); + + it('honors defaults, config, env, and runtime override priority for SMS', () => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'graphql-env-sms-')); + writeConfig(tempDir, { + sms: { + provider: 'devsms', + senderId: 'ConfigSender', + requestTimeoutMs: 3000, + dryRun: false, + devsms: { + baseUrl: 'http://config-devsms:4000', + }, + }, + }); + + const result = getEnvOptions( + { + sms: { + requestTimeoutMs: 9000, + }, + }, + tempDir, + { + SMS_SENDER_ID: 'EnvSender', + SEND_SMS_DRY_RUN: 'true', + DEVSMS_BASE_URL: 'http://env-devsms:4000', + } + ); + + expect(result.sms).toEqual({ + provider: 'devsms', + senderId: 'EnvSender', + requestTimeoutMs: 9000, + dryRun: true, + devsms: { + baseUrl: 'http://env-devsms:4000', + }, + }); + }); + + it('uses the injected env object instead of global process.env for SMS', () => { + const previousSmsProvider = process.env.SMS_PROVIDER; + process.env.SMS_PROVIDER = 'twilio'; + + try { + const result = getEnvOptions({}, process.cwd(), { + SMS_PROVIDER: 'devsms', + }); + + expect(result.sms?.provider).toBe('devsms'); + } finally { + if (previousSmsProvider === undefined) { + delete process.env.SMS_PROVIDER; + } else { + process.env.SMS_PROVIDER = previousSmsProvider; + } + } + }); + + it('keeps SMS provider and DevSms base URL optional while applying defaults', () => { + const result = getEnvOptions({}, process.cwd(), {}); + + expect(result.sms).toEqual({ + requestTimeoutMs: 5000, + dryRun: false, + }); + }); + + it('omits an invalid SMS timeout from partial env overrides', () => { + const result = getGraphQLEnvVars({ + SMS_REQUEST_TIMEOUT_MS: '5s', + }); + + expect(result.sms).toBeUndefined(); + }); + + it('does not let absent or invalid SMS env values override config', () => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'graphql-env-sms-defaults-') + ); + writeConfig(tempDir, { + sms: { + requestTimeoutMs: 3000, + dryRun: true, + }, + }); + + const result = getEnvOptions({}, tempDir, { + SMS_REQUEST_TIMEOUT_MS: '5s', + }); + + expect(result.sms).toEqual({ + requestTimeoutMs: 3000, + dryRun: true, + }); + }); }); diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 2ea2ad5c7..1cf035d74 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -1,10 +1,12 @@ -import { ConstructiveOptions } from '@constructive-io/graphql-types'; -import { parseEnvBoolean } from '12factor-env'; +import type { ConstructiveOptions } from '@constructive-io/graphql-types'; +import { parseEnvBoolean, parseEnvNumber } from '12factor-env'; /** * @param env - Environment object to read from (defaults to process.env for backwards compatibility) */ -export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial => { +export const getGraphQLEnvVars = ( + env: NodeJS.ProcessEnv = process.env +): Partial => { const { GRAPHILE_SCHEMA, @@ -26,29 +28,60 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial CHAT_PROVIDER, CHAT_MODEL, CHAT_BASE_URL, + + SMS_PROVIDER, + SMS_SENDER_ID, + SMS_REQUEST_TIMEOUT_MS, + SEND_SMS_DRY_RUN, + DEVSMS_BASE_URL, } = env; + // Keep this function as a partial env-override parser. Defaults are applied + // before config in constructiveGraphqlDefaults; injecting them here would + // incorrectly let an absent env var overwrite pgpm.json. + const smsRequestTimeoutMs = parseEnvNumber(SMS_REQUEST_TIMEOUT_MS); + const smsDryRun = parseEnvBoolean(SEND_SMS_DRY_RUN); + const hasSmsEnvOverrides = Boolean( + SMS_PROVIDER || + SMS_SENDER_ID || + smsRequestTimeoutMs !== undefined || + smsDryRun !== undefined || + DEVSMS_BASE_URL + ); + return { graphile: { ...(GRAPHILE_SCHEMA && { schema: GRAPHILE_SCHEMA.includes(',') - ? GRAPHILE_SCHEMA.split(',').map(s => s.trim()) - : GRAPHILE_SCHEMA + ? GRAPHILE_SCHEMA.split(',').map((s) => s.trim()) + : GRAPHILE_SCHEMA, }), }, features: { - ...(FEATURES_SIMPLE_INFLECTION && { simpleInflection: parseEnvBoolean(FEATURES_SIMPLE_INFLECTION) }), - ...(FEATURES_OPPOSITE_BASE_NAMES && { oppositeBaseNames: parseEnvBoolean(FEATURES_OPPOSITE_BASE_NAMES) }), + ...(FEATURES_SIMPLE_INFLECTION && { + simpleInflection: parseEnvBoolean(FEATURES_SIMPLE_INFLECTION), + }), + ...(FEATURES_OPPOSITE_BASE_NAMES && { + oppositeBaseNames: parseEnvBoolean(FEATURES_OPPOSITE_BASE_NAMES), + }), ...(FEATURES_POSTGIS && { postgis: parseEnvBoolean(FEATURES_POSTGIS) }), }, api: { - ...(API_ENABLE_SERVICES && { enableServicesApi: parseEnvBoolean(API_ENABLE_SERVICES) }), + ...(API_ENABLE_SERVICES && { + enableServicesApi: parseEnvBoolean(API_ENABLE_SERVICES), + }), ...(API_IS_PUBLIC && { isPublic: parseEnvBoolean(API_IS_PUBLIC) }), - ...(API_EXPOSED_SCHEMAS && { exposedSchemas: API_EXPOSED_SCHEMAS.split(',').map(s => s.trim()) }), - ...(API_META_SCHEMAS && { metaSchemas: API_META_SCHEMAS.split(',').map(s => s.trim()) }), + ...(API_EXPOSED_SCHEMAS && { + exposedSchemas: API_EXPOSED_SCHEMAS.split(',').map((s) => s.trim()), + }), + ...(API_META_SCHEMAS && { + metaSchemas: API_META_SCHEMAS.split(',').map((s) => s.trim()), + }), ...(API_ANON_ROLE && { anonRole: API_ANON_ROLE }), ...(API_ROLE_NAME && { roleName: API_ROLE_NAME }), - ...(API_DEFAULT_DATABASE_ID && { defaultDatabaseId: API_DEFAULT_DATABASE_ID }), + ...(API_DEFAULT_DATABASE_ID && { + defaultDatabaseId: API_DEFAULT_DATABASE_ID, + }), }, ...((EMBEDDER_PROVIDER || CHAT_PROVIDER) && { llm: { @@ -68,5 +101,20 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial }), }, }), + ...(hasSmsEnvOverrides && { + sms: { + ...(SMS_PROVIDER && { provider: SMS_PROVIDER }), + ...(SMS_SENDER_ID && { senderId: SMS_SENDER_ID }), + ...(smsRequestTimeoutMs !== undefined && { + requestTimeoutMs: smsRequestTimeoutMs, + }), + ...(smsDryRun !== undefined && { dryRun: smsDryRun }), + ...(DEVSMS_BASE_URL && { + devsms: { + baseUrl: DEVSMS_BASE_URL, + }, + }), + }, + }), }; }; diff --git a/graphql/env/src/index.ts b/graphql/env/src/index.ts index cfe0e18f1..b19937632 100644 --- a/graphql/env/src/index.ts +++ b/graphql/env/src/index.ts @@ -1,3 +1,4 @@ // Export Constructive-specific env functions export { getEnvOptions, getConstructiveEnvOptions } from './merge'; export { getGraphQLEnvVars } from './env'; +export type { DevSmsOptions, SmsOptions } from '@constructive-io/graphql-types'; diff --git a/graphql/env/src/merge.ts b/graphql/env/src/merge.ts index 669c75269..ef661777c 100644 --- a/graphql/env/src/merge.ts +++ b/graphql/env/src/merge.ts @@ -30,7 +30,7 @@ export const getEnvOptions = ( const graphqlEnvOptions = getGraphQLEnvVars(env); // Load config again to get any GraphQL-specific config - // Config files can contain Constructive options (graphile, features, api) + // Config files can contain Constructive options (graphile, features, api, sms) // even though loadConfigSync returns PgpmOptions type const configOptions = loadConfigSync(cwd) as Partial; @@ -43,6 +43,7 @@ export const getEnvOptions = ( ...(configOptions.graphile && { graphile: configOptions.graphile }), ...(configOptions.features && { features: configOptions.features }), ...(configOptions.api && { api: configOptions.api }), + ...(configOptions.sms && { sms: configOptions.sms }), }, graphqlEnvOptions, overrides diff --git a/graphql/types/src/constructive.ts b/graphql/types/src/constructive.ts index fc64c547c..b3d0e5747 100644 --- a/graphql/types/src/constructive.ts +++ b/graphql/types/src/constructive.ts @@ -19,6 +19,7 @@ import { apiDefaults } from './graphile'; import { LlmOptions } from './llm'; +import { SmsOptions, smsDefaults } from './sms'; /** * GraphQL-specific options for Constructive @@ -30,6 +31,8 @@ export interface ConstructiveGraphQLOptions { features?: GraphileFeatureOptions; /** API configuration options */ api?: ApiOptions; + /** SMS provider configuration */ + sms?: SmsOptions; } /** @@ -59,6 +62,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt jobs?: JobsConfig; /** LLM provider configuration (embeddings, chat, RAG) */ llm?: LlmOptions; + /** SMS provider configuration */ + sms?: SmsOptions; } /** @@ -67,7 +72,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt export const constructiveGraphqlDefaults: ConstructiveGraphQLOptions = { graphile: graphileDefaults, features: graphileFeatureDefaults, - api: apiDefaults + api: apiDefaults, + sms: smsDefaults }; /** diff --git a/graphql/types/src/index.ts b/graphql/types/src/index.ts index a66eb0bf6..9b51f1532 100644 --- a/graphql/types/src/index.ts +++ b/graphql/types/src/index.ts @@ -29,3 +29,10 @@ export { LlmEmbedderOptions, LlmChatOptions } from './llm'; + +// Export SMS types +export { + SmsOptions, + DevSmsOptions, + smsDefaults +} from './sms'; diff --git a/graphql/types/src/sms.ts b/graphql/types/src/sms.ts new file mode 100644 index 000000000..5d55780b2 --- /dev/null +++ b/graphql/types/src/sms.ts @@ -0,0 +1,35 @@ +/** + * SMS provider configuration options for Constructive runtimes. + * + * Production providers are intentionally configuration-only here. Runtime + * packages decide which providers they implement and validate that required + * provider-specific values are present before sending. + */ +export interface DevSmsOptions { + /** Base URL for the local DevSms API, e.g. http://localhost:4000 */ + baseUrl?: string; +} + +export interface SmsOptions { + /** SMS provider implementation to use; runtimes may register custom names. */ + provider?: string; + /** Optional sender ID/default source address for providers that support it. */ + senderId?: string; + /** Outbound provider HTTP timeout in milliseconds. */ + requestTimeoutMs?: number; + /** Validate/render messages without sending them to the provider. */ + dryRun?: boolean; + /** DevSms local provider options. */ + devsms?: DevSmsOptions; +} + +/** + * Honest fallbacks for every environment (12factor-env class 1). + * + * These live in the domain-default layer rather than the env parser so config + * files can override them when the corresponding env variables are absent. + */ +export const smsDefaults: SmsOptions = { + requestTimeoutMs: 5000, + dryRun: false, +};