diff --git a/graphql/env/__tests__/release.test.ts b/graphql/env/__tests__/release.test.ts new file mode 100644 index 0000000000..dc433d1256 --- /dev/null +++ b/graphql/env/__tests__/release.test.ts @@ -0,0 +1,38 @@ +import { getGraphQLEnvVars } from '../src/env'; +import { getEnvOptions } from '../src/merge'; + +const RELEASE_ENV = { + CONSTRUCTIVE_RELEASE_HUB: '880', + CONSTRUCTIVE_RELEASE_CONSTRUCTIVE: '91e18cc', + CONSTRUCTIVE_RELEASE_CONSTRUCTIVE_DB: 'f14937c', + CONSTRUCTIVE_RELEASE_DASHBOARD: '1623557', + CONSTRUCTIVE_RELEASE_FUNCTIONS: 'f14937c' +}; +const RELEASE = { + hub: '880', + constructive: '91e18cc', + constructiveDb: 'f14937c', + dashboard: '1623557', + functions: 'f14937c' +}; + +describe('release tuple environment variables', () => { + it('leaves the release absent when no CONSTRUCTIVE_RELEASE_* variable is set', () => { + expect(getGraphQLEnvVars({}).release).toBeUndefined(); + }); + + it('maps the operator-injected CONSTRUCTIVE_RELEASE_* variables to the release tuple', () => { + expect(getGraphQLEnvVars(RELEASE_ENV).release).toEqual(RELEASE); + expect(getEnvOptions({}, process.cwd(), RELEASE_ENV).release).toEqual(RELEASE); + }); + + it.each(Object.keys(RELEASE_ENV))('rejects a tuple missing %s', (name) => { + const env: NodeJS.ProcessEnv = { ...RELEASE_ENV }; + delete env[name]; + expect(() => getGraphQLEnvVars(env)).toThrow(name); + }); + + it.each(Object.keys(RELEASE_ENV))('rejects an empty %s', (name) => { + expect(() => getGraphQLEnvVars({ ...RELEASE_ENV, [name]: ' ' })).toThrow(name); + }); +}); diff --git a/graphql/env/src/env.ts b/graphql/env/src/env.ts index 4cdecd9680..4d14951709 100644 --- a/graphql/env/src/env.ts +++ b/graphql/env/src/env.ts @@ -5,6 +5,7 @@ import { normalizeGrafastCacheLimits, parseGrafastCacheLimitEnv } from './grafast-cache-limits'; +import { getReleaseEnv } from './release'; import { getScopedIntrospectionEnv } from './scoped-introspection'; /** @@ -12,6 +13,7 @@ import { getScopedIntrospectionEnv } from './scoped-introspection'; */ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial => { const scopedIntrospection = getScopedIntrospectionEnv(env); + const release = getReleaseEnv(env); const { GRAPHILE_SCHEMA, GRAPHILE_EXPLAIN, @@ -141,6 +143,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial } }) } - }) + }), + ...(release && { release }) }; }; diff --git a/graphql/env/src/release.ts b/graphql/env/src/release.ts new file mode 100644 index 0000000000..a60007d348 --- /dev/null +++ b/graphql/env/src/release.ts @@ -0,0 +1,31 @@ +import type { ReleaseOptions } from '@constructive-io/graphql-types'; + +const RELEASE_ENV: Record = { + hub: 'CONSTRUCTIVE_RELEASE_HUB', + constructive: 'CONSTRUCTIVE_RELEASE_CONSTRUCTIVE', + constructiveDb: 'CONSTRUCTIVE_RELEASE_CONSTRUCTIVE_DB', + dashboard: 'CONSTRUCTIVE_RELEASE_DASHBOARD', + functions: 'CONSTRUCTIVE_RELEASE_FUNCTIONS' +}; + +/** + * The release tuple is all-or-nothing: absent when no CONSTRUCTIVE_RELEASE_* + * variable is set, otherwise every field must be a non-empty value. + */ +export const getReleaseEnv = (env: NodeJS.ProcessEnv): ReleaseOptions | undefined => { + const entries = Object.entries(RELEASE_ENV) as [keyof ReleaseOptions, string][]; + if (entries.every(([, name]) => env[name] === undefined)) { + return undefined; + } + const invalid = entries + .filter(([, name]) => !env[name]?.trim()) + .map(([, name]) => name); + if (invalid.length > 0) { + throw new Error( + `Incomplete release tuple: ${invalid.join(', ')} must be set to a non-empty value when any CONSTRUCTIVE_RELEASE_* variable is set` + ); + } + return Object.fromEntries( + entries.map(([field, name]) => [field, env[name].trim()]) + ) as unknown as ReleaseOptions; +}; diff --git a/graphql/server/src/middleware/__tests__/release.test.ts b/graphql/server/src/middleware/__tests__/release.test.ts new file mode 100644 index 0000000000..691f1b2302 --- /dev/null +++ b/graphql/server/src/middleware/__tests__/release.test.ts @@ -0,0 +1,73 @@ +import type { ConstructiveOptions, ReleaseOptions } from '@constructive-io/graphql-types'; +import supertest from 'supertest'; + +import { Server } from '../../server'; +import { createReleaseHandler } from '../release'; + +// Field list consumed by constructive-hub tests/acceptance/graphql.ts +// (RELEASE_FIELDS) and the .github/workflows/live.yml preflight. +const RELEASE_FIELDS = ['hub', 'constructive', 'constructiveDb', 'dashboard', 'functions']; +const RELEASE_ENV = { + CONSTRUCTIVE_RELEASE_HUB: '880', + CONSTRUCTIVE_RELEASE_CONSTRUCTIVE: '91e18cc', + CONSTRUCTIVE_RELEASE_CONSTRUCTIVE_DB: 'f14937c', + CONSTRUCTIVE_RELEASE_DASHBOARD: '1623557', + CONSTRUCTIVE_RELEASE_FUNCTIONS: 'f14937c' +}; + +const appWith = (env: Record) => { + const previous = { ...process.env }; + Object.assign(process.env, env); + try { + const server = new Server({} as ConstructiveOptions); + return (server as unknown as { app: Parameters[0] }).app; + } finally { + process.env = previous; + } +}; + +describe('GET /.well-known/constructive-release', () => { + it('serves the operator-injected release tuple as JSON on any host', async () => { + const res = await supertest(appWith(RELEASE_ENV)) + .get('/.well-known/constructive-release') + .set('Host', 'api.launchql.dev') + .expect(200) + .expect('Content-Type', /application\/json/) + .expect('Cache-Control', 'no-store'); + + expect(Object.keys(res.body).sort()).toEqual([...RELEASE_FIELDS].sort()); + expect(res.body).toEqual({ + hub: '880', + constructive: '91e18cc', + constructiveDb: 'f14937c', + dashboard: '1623557', + functions: 'f14937c' + }); + }); +}); + +describe('createReleaseHandler', () => { + const RELEASE = { + hub: '880', + constructive: '91e18cc', + constructiveDb: 'f14937c', + dashboard: '1623557', + functions: 'f14937c' + }; + + it.each(RELEASE_FIELDS)('rejects a runtime release option missing %s', (field) => { + const release = { ...RELEASE } as Record; + delete release[field]; + expect(() => createReleaseHandler(release as unknown as ReleaseOptions)).toThrow(field); + }); + + it.each(RELEASE_FIELDS)('rejects a runtime release option with an empty %s', (field) => { + expect(() => createReleaseHandler({ ...RELEASE, [field]: ' ' })).toThrow(field); + }); + + it('refuses to construct a Server from a partial runtime release option', () => { + expect( + () => new Server({ release: { hub: '880' } } as unknown as ConstructiveOptions) + ).toThrow('constructiveDb'); + }); +}); diff --git a/graphql/server/src/middleware/release.ts b/graphql/server/src/middleware/release.ts new file mode 100644 index 0000000000..d968ab0ef0 --- /dev/null +++ b/graphql/server/src/middleware/release.ts @@ -0,0 +1,30 @@ +import type { ReleaseOptions } from '@constructive-io/graphql-types'; +import type { RequestHandler } from 'express'; + +export const RELEASE_PATH = '/.well-known/constructive-release'; + +const RELEASE_FIELDS: (keyof ReleaseOptions)[] = [ + 'hub', + 'constructive', + 'constructiveDb', + 'dashboard', + 'functions' +]; + +export const createReleaseHandler = (release: ReleaseOptions): RequestHandler => { + const invalid = RELEASE_FIELDS.filter( + (field) => typeof release[field] !== 'string' || release[field].trim() === '' + ); + if (invalid.length > 0) { + throw new Error( + `Incomplete release tuple: ${invalid.join(', ')} must be a non-empty string` + ); + } + const tuple = Object.fromEntries( + RELEASE_FIELDS.map((field) => [field, release[field]]) + ) as unknown as ReleaseOptions; + return (_req, res) => { + res.set('Cache-Control', 'no-store'); + res.json(tuple); + }; +}; diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index ac2c839618..5c94069758 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -43,6 +43,7 @@ import { createDebugDatabaseMiddleware } from './middleware/observability/debug- import { debugMemory } from './middleware/observability/debug-memory'; import { localObservabilityOnly } from './middleware/observability/guard'; import { createRequestLogger } from './middleware/observability/request-logger'; +import { createReleaseHandler, RELEASE_PATH } from './middleware/release'; import { createRequestProtectionMiddleware } from './middleware/request-protection'; import { createStandingMiddleware } from './middleware/standing'; import { createPlatformRefusalRecorder, installRefusalRecorder } from './refusals/recorder'; @@ -129,6 +130,9 @@ class Server { } healthz(app); + if (effectiveOpts.release) { + app.get(RELEASE_PATH, createReleaseHandler(effectiveOpts.release)); + } if (observabilityEnabled) { app.get('/debug/memory', localObservabilityOnly, debugMemory); app.get('/debug/db', localObservabilityOnly, createDebugDatabaseMiddleware(effectiveOpts)); diff --git a/graphql/types/src/constructive.ts b/graphql/types/src/constructive.ts index c3d5c3da71..e365f87bcc 100644 --- a/graphql/types/src/constructive.ts +++ b/graphql/types/src/constructive.ts @@ -17,6 +17,7 @@ import { GraphileFeatureOptions, GraphileOptions} from './graphile'; import { LlmOptions } from './llm'; +import { ReleaseOptions } from './release'; import { SmsOptions } from './sms'; /** @@ -58,6 +59,8 @@ export interface ConstructiveOptions extends PgpmOptions, ConstructiveGraphQLOpt llm?: LlmOptions; /** SMS provider configuration */ sms?: SmsOptions; + /** Deployed release tuple served at /.well-known/constructive-release */ + release?: ReleaseOptions; } /** diff --git a/graphql/types/src/index.ts b/graphql/types/src/index.ts index db2333a7b7..56ac3becd2 100644 --- a/graphql/types/src/index.ts +++ b/graphql/types/src/index.ts @@ -33,6 +33,9 @@ export { LlmEmbedderOptions, LlmOptions} from './llm'; +// Export release types +export { ReleaseOptions } from './release'; + // Export SMS types export { DevSmsOptions, diff --git a/graphql/types/src/release.ts b/graphql/types/src/release.ts new file mode 100644 index 0000000000..e9f621b292 --- /dev/null +++ b/graphql/types/src/release.ts @@ -0,0 +1,12 @@ +/** + * The deployed release tuple. The cloud operator injects it from + * ConstructiveApp.spec.release as CONSTRUCTIVE_RELEASE_* and the server serves + * it at /.well-known/constructive-release for live acceptance. + */ +export interface ReleaseOptions { + hub: string; + constructive: string; + constructiveDb: string; + dashboard: string; + functions: string; +}