Skip to content
Closed
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
38 changes: 38 additions & 0 deletions graphql/env/__tests__/release.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
5 changes: 4 additions & 1 deletion graphql/env/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import {
normalizeGrafastCacheLimits,
parseGrafastCacheLimitEnv
} from './grafast-cache-limits';
import { getReleaseEnv } from './release';
import { getScopedIntrospectionEnv } from './scoped-introspection';

/**
* @param env - Environment object to read from (defaults to process.env for backwards compatibility)
*/
export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial<ConstructiveOptions> => {
const scopedIntrospection = getScopedIntrospectionEnv(env);
const release = getReleaseEnv(env);
const {
GRAPHILE_SCHEMA,
GRAPHILE_EXPLAIN,
Expand Down Expand Up @@ -141,6 +143,7 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial
}
})
}
})
}),
...(release && { release })
};
};
31 changes: 31 additions & 0 deletions graphql/env/src/release.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { ReleaseOptions } from '@constructive-io/graphql-types';

const RELEASE_ENV: Record<keyof ReleaseOptions, string> = {
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;
};
73 changes: 73 additions & 0 deletions graphql/server/src/middleware/__tests__/release.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>) => {
const previous = { ...process.env };
Object.assign(process.env, env);
try {
const server = new Server({} as ConstructiveOptions);
return (server as unknown as { app: Parameters<typeof supertest>[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<string, string>;
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');
});
});
30 changes: 30 additions & 0 deletions graphql/server/src/middleware/release.ts
Original file line number Diff line number Diff line change
@@ -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);
};
};
4 changes: 4 additions & 0 deletions graphql/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
Expand Down
3 changes: 3 additions & 0 deletions 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 { ReleaseOptions } from './release';
import { SmsOptions } from './sms';

/**
Expand Down Expand Up @@ -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;
}

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

// Export release types
export { ReleaseOptions } from './release';

// Export SMS types
export {
DevSmsOptions,
Expand Down
12 changes: 12 additions & 0 deletions graphql/types/src/release.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Loading