Skip to content
Open
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
93 changes: 51 additions & 42 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
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:
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 postgres service)
PGHOST: 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-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
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,7 +100,6 @@ 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
Expand All @@ -95,10 +108,8 @@ services:
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
Expand All @@ -113,7 +124,7 @@ services:

# 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,12 +134,10 @@ 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"
Expand Down
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
86 changes: 81 additions & 5 deletions functions/send-email-link/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,22 +77,47 @@ 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.
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;
}

// 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;
}

return new GraphQLClient(url, { headers });
};

Expand Down Expand Up @@ -163,7 +188,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 @@ -313,15 +350,38 @@ 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,
meta,
databaseId
});

// Validation failures return { missing: '...' } - treat as client error
if (result && typeof result === 'object' && 'missing' in result) {
return res.status(400).json({ error: `Missing required field: ${result.missing}` });
}

res.status(200).json(result);
} catch (err) {
next(err);
Expand All @@ -333,6 +393,22 @@ export default app;
// When executed directly (e.g. via `node dist/index.js`), start an HTTP server.
if (require.main === module) {
const port = Number(process.env.PORT ?? 8080);

// Log startup configuration (non-sensitive values only - no API keys or tokens)
logger.info('[send-email-link] Starting with config:', {
port,
graphqlUrl: process.env.GRAPHQL_URL || 'not set',
metaGraphqlUrl: process.env.META_GRAPHQL_URL || process.env.GRAPHQL_URL || 'not set',
apiName: process.env.GRAPHQL_API_NAME || 'not set',
defaultDatabaseId: process.env.DEFAULT_DATABASE_ID || 'not set',
dryRun: isDryRun,
useSmtp,
mailgunDomain: process.env.MAILGUN_DOMAIN || 'not set',
mailgunFrom: process.env.MAILGUN_FROM || 'not set',
localAppPort: process.env.LOCAL_APP_PORT || 'not set',
hasAuthToken: !!process.env.GRAPHQL_AUTH_TOKEN
});

// @constructive-io/knative-job-fn exposes a .listen method that delegates to the Express app
(app as any).listen(port, () => {
logger.info(`listening on port ${port}`);
Expand Down
18 changes: 9 additions & 9 deletions graphql/server/src/middleware/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,14 +169,14 @@ const getHardCodedSchemata = ({
dbname: opts.pg.database,
anonRole: 'administrator',
roleName: 'administrator',
schemaNamesFromExt: {
apiExtensions: {
nodes: schemata
.split(',')
.map((schema) => schema.trim())
.map((schemaName) => ({ schemaName })),
},
schemaNames: { nodes: [] as Array<{ schemaName: string }> },
apiModules: [] as Array<any>,
schemasByApiSchemaApiIdAndSchemaId: { nodes: [] as Array<{ schemaName: string }> },
apiModules: { nodes: [] as Array<any> },
},
},
};
Expand All @@ -203,11 +203,11 @@ const getMetaSchema = ({
dbname: opts.pg.database,
anonRole: 'administrator',
roleName: 'administrator',
schemaNamesFromExt: {
apiExtensions: {
nodes: schemata.map((schemaName: string) => ({ schemaName })),
},
schemaNames: { nodes: [] as Array<{ schemaName: string }> },
apiModules: [] as Array<any>,
schemasByApiSchemaApiIdAndSchemaId: { nodes: [] as Array<{ schemaName: string }> },
apiModules: { nodes: [] as Array<any> },
},
},
};
Expand Down Expand Up @@ -278,10 +278,10 @@ const queryServiceByApiName = async ({
return null;
}

const data = result?.data;
const api = result?.data?.apiByDatabaseIdAndName;
const apiPublic = (opts as any).api?.isPublic;
if (data?.api && data.api.isPublic === apiPublic) {
const svc = { data };
if (api && api.isPublic === apiPublic) {
const svc = { data: { api } };
svcCache.set(key, svc);
return svc;
}
Expand Down
24 changes: 15 additions & 9 deletions graphql/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,15 +77,21 @@ class Server {
next();
};

// Log startup config in dev mode
if (isDev()) {
log.debug(
`Database: ${effectiveOpts.pg?.database}@${effectiveOpts.pg?.host}:${effectiveOpts.pg?.port}`
);
log.debug(
`Meta schemas: ${(effectiveOpts as any).api?.metaSchemas?.join(', ') || 'default'}`
);
}
// Log startup configuration (non-sensitive values only)
const apiOpts = (effectiveOpts as any).api || {};
log.info('[server] Starting with config:', {
database: effectiveOpts.pg?.database,
host: effectiveOpts.pg?.host,
port: effectiveOpts.pg?.port,
serverHost: effectiveOpts.server?.host,
serverPort: effectiveOpts.server?.port,
apiIsPublic: apiOpts.isPublic,
enableServicesApi: apiOpts.enableServicesApi,
metaSchemas: apiOpts.metaSchemas?.join(',') || 'default',
exposedSchemas: apiOpts.exposedSchemas?.join(',') || 'none',
anonRole: apiOpts.anonRole,
roleName: apiOpts.roleName
});

healthz(app);
trustProxy(app, effectiveOpts.server.trustProxy);
Expand Down
Loading