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
109 changes: 59 additions & 50 deletions docker-compose.jobs.yml
Original file line number Diff line number Diff line change
@@ -1,64 +1,76 @@
services:
# Constructive GraphQL API server
constructive-server:
container_name: constructive-server
# Constructive Admin GraphQL API server (internal, header-based routing)
constructive-admin-server:
container_name: constructive-admin-server
image: constructive:dev
build:
context: .
dockerfile: ./Dockerfile
# The image entrypoint already runs the Constructive CLI (`constructive`).
# We only need to provide the subcommand and flags here.
entrypoint: ["constructive", "server", "--port", "3000", "--origin", "*", "--strictAuth", "false"]
entrypoint: ["constructive", "server", "--host", "0.0.0.0", "--port", "3000", "--origin", "*"]
environment:
NODE_ENV: development
# Server
PORT: "3000"
SERVER_HOST: "0.0.0.0"
SERVER_TRUST_PROXY: "true"
SERVER_ORIGIN: "*" # allow all origins in dev
SERVER_ORIGIN: "*"
SERVER_STRICT_AUTH: "false"
# Postgres connection (matches postgres service)
PGHOST: postgres
# Postgres connection (matches constructive-tests-postgres service)
PGHOST: constructive-tests-postgres
PGPORT: "5432"
PGUSER: postgres
PGPASSWORD: password
PGDATABASE: constructive
# API meta configuration (static mode for dev)
API_ENABLE_META: "true"
API_EXPOSED_SCHEMAS: "metaschema_public,services_public"
# Api configuration
API_ENABLE_SERVICES: "true"
API_EXPOSED_SCHEMAS: "metaschema_public,services_public,constructive_auth_public"
# API_IS_PUBLIC=false enables header-based routing (X-Api-Name, X-Database-Id, X-Meta-Schema)
API_IS_PUBLIC: "false"
# Meta schemas used for schema validation and X-Meta-Schema routing
API_META_SCHEMAS: "metaschema_public,services_public,metaschema_modules_public,constructive_auth_public"
API_ANON_ROLE: "administrator"
API_ROLE_NAME: "administrator"
API_DEFAULT_DATABASE_ID: "dbe"
ports:
- "3000:3000"
- "3101:3000"
networks:
constructive-net:
constructive-tests-net:
aliases:
# Let other containers call the admin API using the seeded domain route.
- admin.localhost
- constructive-admin-server

# Simple email function (Knative-style HTTP function)
simple-email:
container_name: simple-email
# Constructive Public GraphQL API server (external, domain-based routing)
constructive-server:
container_name: constructive-server
image: constructive:dev
# Override the image entrypoint (Constructive CLI) and run the Node function directly.
entrypoint: ["node", "functions/simple-email/dist/index.js"]
entrypoint: ["constructive", "server", "--host", "0.0.0.0", "--port", "3000", "--origin", "*"]
environment:
NODE_ENV: development
LOG_LEVEL: info
SIMPLE_EMAIL_DRY_RUN: "${SIMPLE_EMAIL_DRY_RUN:-true}"
# Mailgun / email provider configuration for the Postmaster package
# Replace with real credentials for local testing.
MAILGUN_API_KEY: "${MAILGUN_API_KEY:-change-me-mailgun-api-key}"
MAILGUN_KEY: "${MAILGUN_KEY:-change-me-mailgun-api-key}"
MAILGUN_DOMAIN: "mg.constructive.io"
MAILGUN_FROM: "no-reply@mg.constructive.io"
MAILGUN_REPLY: "info@mg.constructive.io"
# Server
PORT: "3000"
SERVER_HOST: "0.0.0.0"
SERVER_TRUST_PROXY: "true"
SERVER_ORIGIN: "*"
SERVER_STRICT_AUTH: "false"
# Postgres connection (matches constructive-tests-postgres service)
PGHOST: constructive-tests-postgres
PGPORT: "5432"
PGUSER: postgres
PGPASSWORD: password
PGDATABASE: constructive
# Api configuration
API_ENABLE_SERVICES: "false"
API_EXPOSED_SCHEMAS: "metaschema_public,services_public,constructive_auth_public"
# Public-facing server
API_IS_PUBLIC: "true"
# Meta schemas used for schema validation
API_META_SCHEMAS: "metaschema_public,services_public,metaschema_modules_public,constructive_auth_public"
API_ANON_ROLE: "anonymous"
API_ROLE_NAME: "authenticated"
ports:
# Expose function locally (optional)
- "8081:8080"
- "3102:3000"
networks:
- constructive-net
constructive-tests-net:
aliases:
- constructive-server

# Send email link function (invite, password reset, verification)
send-email-link:
Expand All @@ -69,9 +81,11 @@ services:
NODE_ENV: development
LOG_LEVEL: info
DEFAULT_DATABASE_ID: "dbe"
# Constructive selects the API by Host header; use a seeded domain route.
GRAPHQL_URL: "http://admin.localhost:3000/graphql"
META_GRAPHQL_URL: "http://admin.localhost:3000/graphql"
# Point to admin server (uses X-Api-Name header routing when API_IS_PUBLIC=false)
GRAPHQL_URL: "http://constructive-admin-server:3000/graphql"
META_GRAPHQL_URL: "http://constructive-admin-server:3000/graphql"
# API name for header-based routing (X-Api-Name header) - kept for future use
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment states 'kept for future use' but GRAPHQL_API_NAME is set to 'private'. This is confusing - either the variable is currently used (in which case update the comment) or it's not needed yet (in which case consider removing it until actually required).

Suggested change
# API name for header-based routing (X-Api-Name header) - kept for future use
# API name for header-based routing (X-Api-Name header), e.g. when API_IS_PUBLIC=false

Copilot uses AI. Check for mistakes.
GRAPHQL_API_NAME: "private"
# Optional: provide an existing API token (Bearer) if your server requires it.
GRAPHQL_AUTH_TOKEN: "${GRAPHQL_AUTH_TOKEN:-}"
# Mailgun / email provider configuration for the Postmaster package
Expand All @@ -86,34 +100,31 @@ services:
LOCAL_APP_PORT: "3000"
SEND_EMAIL_LINK_DRY_RUN: "${SEND_EMAIL_LINK_DRY_RUN:-true}"
ports:
# Expose function locally (optional)
- "8082:8080"
networks:
- constructive-net
- constructive-tests-net

# Jobs runtime: callback server + worker + scheduler
knative-job-service:
container_name: knative-job-service
image: constructive:dev
# Override the image entrypoint and run the jobs runtime directly.
entrypoint: ["node", "jobs/knative-job-service/dist/run.js"]
depends_on:
- simple-email
- send-email-link
environment:
NODE_ENV: development

# Postgres (jobs extension lives in this DB)
PGUSER: postgres
PGHOST: postgres
PGHOST: constructive-tests-postgres
PGPASSWORD: password
PGPORT: "5432"
PGDATABASE: constructive
JOBS_SCHEMA: app_jobs

# Worker configuration
JOBS_SUPPORT_ANY: "false"
JOBS_SUPPORTED: "simple-email,send-email-link"
JOBS_SUPPORTED: "send-email-link"
HOSTNAME: "knative-job-service-1"

# Callback HTTP server (job completion callbacks)
Expand All @@ -123,19 +134,17 @@ services:
JOBS_CALLBACK_HOST: "knative-job-service"

# Function gateway base URL (used by worker when no dev map is present)
INTERNAL_GATEWAY_URL: "http://simple-email:8080"
INTERNAL_GATEWAY_URL: "http://send-email-link:8080"

# Development-only map from task identifier -> function URL
# Used by @constructive-io/knative-job-worker when NODE_ENV !== 'production'.
# This lets the worker call the function containers directly in docker-compose.
INTERNAL_GATEWAY_DEVELOPMENT_MAP: '{"simple-email":"http://simple-email:8080","send-email-link":"http://send-email-link:8080"}'
INTERNAL_GATEWAY_DEVELOPMENT_MAP: '{"send-email-link":"http://send-email-link:8080"}'

ports:
- "8080:8080"
networks:
- constructive-net
- constructive-tests-net

networks:
constructive-net:
constructive-tests-net:
external: true
name: constructive-net
name: constructive-tests-net
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
services:
postgres:
container_name: postgres
image: pyramation/pgvector:13.3-alpine
image: ghcr.io/constructive-io/docker/postgres-plus:17
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=password
Expand Down
67 changes: 61 additions & 6 deletions functions/send-email-link/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,20 +72,45 @@ const getRequiredEnv = (name: string): string => {
return value;
};

type GraphQLClientOptions = {
hostHeaderEnvVar?: string;
databaseId?: string;
useMetaSchema?: boolean;
apiName?: string;
schemata?: string;
};

// TODO: Consider moving this to @constructive-io/knative-job-fn as a shared
// utility so all job functions can create GraphQL clients with consistent
// header-based routing without duplicating this logic.
Comment on lines +83 to +85
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This TODO comment identifies important refactoring work to share GraphQL client creation logic across job functions. Consider creating a tracking issue or documenting this technical debt in a more visible location to ensure it's addressed before this PR is finalized.

Copilot uses AI. Check for mistakes.
const createGraphQLClient = (
url: string,
hostHeaderEnvVar?: string
options: GraphQLClientOptions = {}
): GraphQLClient => {
const headers: Record<string, string> = {};

if (process.env.GRAPHQL_AUTH_TOKEN) {
headers.Authorization = `Bearer ${process.env.GRAPHQL_AUTH_TOKEN}`;
}

const envName = hostHeaderEnvVar || 'GRAPHQL_HOST_HEADER';
const envName = options.hostHeaderEnvVar || 'GRAPHQL_HOST_HEADER';
const hostHeader = process.env[envName];
if (hostHeader) {
headers.host = hostHeader;
headers.Host = hostHeader;
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTP header names should be lowercase when setting them programmatically. The graphql-request library expects lowercase header names. Change 'Host' to 'host' to ensure proper header transmission.

Suggested change
headers.Host = hostHeader;
headers.host = hostHeader;

Copilot uses AI. Check for mistakes.
}

// Header-based routing for internal cluster services (API_IS_PUBLIC=false)
if (options.databaseId) {
headers['X-Database-Id'] = options.databaseId;
}
if (options.useMetaSchema) {
headers['X-Meta-Schema'] = 'true';
}
if (options.apiName) {
headers['X-Api-Name'] = options.apiName;
}
if (options.schemata) {
headers['X-Schemata'] = options.schemata;
}
Comment on lines +102 to 114
Copy link

Copilot AI Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new header-based routing logic with multiple conditional header additions lacks test coverage. Consider adding unit tests to verify correct header construction for different option combinations.

Copilot uses AI. Check for mistakes.

return new GraphQLClient(url, { headers });
Expand Down Expand Up @@ -158,7 +183,19 @@ export const sendEmailLink = async (
const name = company.name;
const primary = theme.primary;

const hostname = subdomain ? [subdomain, domain].join('.') : domain;
// Check if this is a localhost-style domain before building hostname
// TODO: Security consideration - this only affects localhost domains which
// should not exist in production. The isLocalHost check combined with isDryRun
// ensures special behavior (http, custom port) only applies in dev environments.
const isLocalDomain =
domain === 'localhost' ||
domain.startsWith('localhost') ||
domain === '0.0.0.0';

// For localhost, skip subdomain to generate cleaner URLs (http://localhost:3000)
const hostname = subdomain && !isLocalDomain
? [subdomain, domain].join('.')
: domain;

// Treat localhost-style hosts specially so we can generate
// http://localhost[:port]/... links for local dev without
Expand Down Expand Up @@ -308,8 +345,26 @@ app.post('/', async (req: any, res: any, next: any) => {
const graphqlUrl = getRequiredEnv('GRAPHQL_URL');
const metaGraphqlUrl = process.env.META_GRAPHQL_URL || graphqlUrl;

const client = createGraphQLClient(graphqlUrl, 'GRAPHQL_HOST_HEADER');
const meta = createGraphQLClient(metaGraphqlUrl, 'META_GRAPHQL_HOST_HEADER');
// Get API name or schemata from env (for tenant queries like GetUser)
const apiName = process.env.GRAPHQL_API_NAME;
const schemata = process.env.GRAPHQL_SCHEMATA;

// For GetUser query - needs tenant API access via X-Api-Name or X-Schemata
const client = createGraphQLClient(graphqlUrl, {
hostHeaderEnvVar: 'GRAPHQL_HOST_HEADER',
databaseId,
...(apiName && { apiName }),
...(schemata && { schemata }),
});

// For GetDatabaseInfo query - uses same API routing as client
// The private API exposes both user and database queries
const meta = createGraphQLClient(metaGraphqlUrl, {
hostHeaderEnvVar: 'META_GRAPHQL_HOST_HEADER',
databaseId,
...(apiName && { apiName }),
...(schemata && { schemata }),
});

const result = await sendEmailLink(params, {
client,
Expand Down
6 changes: 3 additions & 3 deletions graphql/env/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial

return {
graphile: {
...(GRAPHILE_SCHEMA && {
schema: GRAPHILE_SCHEMA.includes(',')
...(GRAPHILE_SCHEMA && {
schema: GRAPHILE_SCHEMA.includes(',')
? GRAPHILE_SCHEMA.split(',').map(s => s.trim())
: GRAPHILE_SCHEMA
: GRAPHILE_SCHEMA
}),
},
features: {
Expand Down
Loading