Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
101 changes: 100 additions & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ jobs:
- batch: packages-core
packages: 'packages/url-domains postgres/query-builder packages/csrf packages/oauth packages/12factor-env packages/orm'
- batch: packages-services
packages: 'packages/postmaster packages/smtppostmaster packages/csv-to-pg packages/cli postgres/pgsql-client postgres/pg-ast'
packages: 'packages/postmaster packages/smtppostmaster packages/csv-to-pg packages/cli-runtime packages/cli postgres/pgsql-client postgres/pg-ast'
- batch: graphql
packages: 'graphql/query graphql/codegen'
- batch: pglite
Expand Down Expand Up @@ -138,6 +138,105 @@ jobs:
echo "::endgroup::"
done

# Pack once from the Node 22 build, then exercise the published package
# boundary on every supported Node line. This intentionally installs the
# tarballs in an empty project so workspace links cannot mask packaging bugs.
cli-package-artifacts:
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Download workspace
uses: actions/download-artifact@v4
with:
name: workspace-build

- name: Extract workspace
run: tar -xzf workspace.tar.gz && rm workspace.tar.gz

- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 10

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Pack CNC release set
run: |
mkdir -p /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/cli-runtime pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/graphql-codegen pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/graphql-explorer pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/graphql-query pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/graphql-server pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/express-context pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/job-pg pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/job-scheduler pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/job-utils pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/knative-job-fn pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/knative-job-service pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/knative-job-worker pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/send-email-fn pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/send-verification-link-fn pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @pgpmjs/env pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @pgpmjs/logger pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @pgpmjs/server-utils pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter pg-cache pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter pg-env pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter graphile-cache pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter graphile-bucket-provisioner-plugin pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter graphile-schema pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter graphile-settings pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter graphile-presigned-url-plugin pack --pack-destination /tmp/cnc-package-artifacts
npm_config_ignore_scripts=true pnpm --filter @constructive-io/cli pack --pack-destination /tmp/cnc-package-artifacts
cp packages/cli/scripts/verify-packed-artifacts.mjs /tmp/cnc-package-artifacts/
cp packages/cli/scripts/packed-acceptance-support.mjs /tmp/cnc-package-artifacts/

- name: Upload package artifacts
uses: actions/upload-artifact@v4
with:
name: cnc-package-artifacts
path: /tmp/cnc-package-artifacts
retention-days: 1

cli-package-matrix:
needs: cli-package-artifacts
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
include:
- node-version: '18.17.0'
suite: core
- node-version: '20'
suite: core
- node-version: '22'
suite: core
- node-version: '22.19.0'
suite: full
steps:
- name: Download package artifacts
uses: actions/download-artifact@v4
with:
name: cnc-package-artifacts
path: cnc-package-artifacts

- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}

- name: Verify installed package entrypoints
run: node cnc-package-artifacts/verify-packed-artifacts.mjs --artifacts cnc-package-artifacts --suite ${{ matrix.suite }}

# =========================================================================
# TIER 2 – PostgreSQL-only tests
# =========================================================================
Expand Down
23 changes: 15 additions & 8 deletions functions/send-email/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { createJobApp } from '@constructive-io/knative-job-fn';
import {
createJobApp,
getJobFunctionEnvironment,
} from '@constructive-io/knative-job-fn';
import { send as sendSmtp } from 'simple-smtp-server';
import { send as sendPostmaster } from '@constructive-io/postmaster';
import { parseEnvBoolean } from '@pgpmjs/env';
Expand Down Expand Up @@ -27,8 +30,12 @@ const getRequiredField = (
return value;
};

const isDryRun = parseEnvBoolean(process.env.SEND_EMAIL_DRY_RUN ?? process.env.SIMPLE_EMAIL_DRY_RUN) ?? false;
const useSmtp = parseEnvBoolean(process.env.EMAIL_SEND_USE_SMTP) ?? false;
const runtimeEnv = getJobFunctionEnvironment();
const isDryRun =
parseEnvBoolean(
runtimeEnv.SEND_EMAIL_DRY_RUN ?? runtimeEnv.SIMPLE_EMAIL_DRY_RUN
) ?? false;
const useSmtp = parseEnvBoolean(runtimeEnv.EMAIL_SEND_USE_SMTP) ?? false;
const logger = createLogger('send-email');
const app = createJobApp();

Expand All @@ -46,7 +53,7 @@ app.post('/', async (req: any, res: any, next: any) => {
throw new Error("Either 'html' or 'text' must be provided");
}

const fromEnv = useSmtp ? process.env.SMTP_FROM : process.env.MAILGUN_FROM;
const fromEnv = useSmtp ? runtimeEnv.SMTP_FROM : runtimeEnv.MAILGUN_FROM;
const from = isNonEmptyString(payload.from)
? payload.from
: isNonEmptyString(fromEnv)
Expand All @@ -63,7 +70,7 @@ app.post('/', async (req: any, res: any, next: any) => {
from,
replyTo,
hasHtml: Boolean(html),
hasText: Boolean(text)
hasText: Boolean(text),
};

if (isDryRun) {
Expand All @@ -77,7 +84,7 @@ app.post('/', async (req: any, res: any, next: any) => {
...(html && { html }),
...(text && { text }),
...(from && { from }),
...(replyTo && { replyTo })
...(replyTo && { replyTo }),
});

logger.info('Sent email', logContext);
Expand All @@ -93,8 +100,8 @@ export default app;

// When executed directly (e.g. `node dist/index.js` in Knative),
// start an HTTP server on the provided PORT (default 8080).
if (require.main === module) {
const port = Number(process.env.PORT ?? 8080);
if (typeof require !== 'undefined' && require.main === module) {
const port = Number(runtimeEnv.PORT ?? 8080);
// @constructive-io/knative-job-fn exposes a .listen method that delegates to the underlying Express app
(app as any).listen(port, () => {
logger.info(`listening on port ${port}`);
Expand Down
7 changes: 4 additions & 3 deletions functions/send-email/tsconfig.esm.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "esnext",
"moduleResolution": "bundler"
"outDir": "dist/esm",
"module": "es2022",
"rootDir": "src/",
"declaration": false
}
}

86 changes: 50 additions & 36 deletions functions/send-verification-link/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { createJobApp } from '@constructive-io/knative-job-fn';
import {
createJobApp,
getJobFunctionEnvironment,
} from '@constructive-io/knative-job-fn';
import { GraphQLClient } from 'graphql-request';
import gql from 'graphql-tag';
import { generate } from '@launchql/mjml';
Expand All @@ -7,8 +10,13 @@ import { send as sendSmtp } from 'simple-smtp-server';
import { parseEnvBoolean } from '@pgpmjs/env';
import { createLogger } from '@pgpmjs/logger';

const isDryRun = parseEnvBoolean(process.env.SEND_VERIFICATION_LINK_DRY_RUN ?? process.env.SEND_EMAIL_LINK_DRY_RUN) ?? false;
const useSmtp = parseEnvBoolean(process.env.EMAIL_SEND_USE_SMTP) ?? false;
const runtimeEnv = getJobFunctionEnvironment();
const isDryRun =
parseEnvBoolean(
runtimeEnv.SEND_VERIFICATION_LINK_DRY_RUN ??
runtimeEnv.SEND_EMAIL_LINK_DRY_RUN
) ?? false;
const useSmtp = parseEnvBoolean(runtimeEnv.EMAIL_SEND_USE_SMTP) ?? false;
const logger = createLogger('send-verification-link');
const app = createJobApp();

Expand Down Expand Up @@ -74,7 +82,7 @@ type GraphQLContext = {
};

const getRequiredEnv = (name: string): string => {
const value = process.env[name];
const value = runtimeEnv[name];
if (!value) {
throw new Error(`Missing required environment variable ${name}`);
}
Expand All @@ -98,12 +106,12 @@ const createGraphQLClient = (
): GraphQLClient => {
const headers: Record<string, string> = {};

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

const envName = options.hostHeaderEnvVar || 'GRAPHQL_HOST_HEADER';
const hostHeader = process.env[envName];
const hostHeader = runtimeEnv[envName];
if (hostHeader) {
headers.host = hostHeader;
}
Expand Down Expand Up @@ -166,7 +174,7 @@ export const sendEmailLink = async (
}

const databaseInfo = await meta.request<any>(GetDatabaseInfo, {
databaseId
databaseId,
});

const database = databaseInfo?.databases?.nodes?.[0];
Expand Down Expand Up @@ -203,9 +211,8 @@ export const sendEmailLink = async (
domain === '0.0.0.0';

// For localhost, skip subdomain to generate cleaner URLs (http://localhost:3000)
const hostname = subdomain && !isLocalDomain
? [subdomain, domain].join('.')
: domain;
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 All @@ -217,8 +224,8 @@ export const sendEmailLink = async (

// When localhost + LOCAL_APP_PORT: use http://localhost:PORT (local dev)
// Otherwise: https (production)
const useLocalUrl = isLocalHost && process.env.LOCAL_APP_PORT;
const localPort = useLocalUrl ? `:${process.env.LOCAL_APP_PORT}` : '';
const useLocalUrl = isLocalHost && runtimeEnv.LOCAL_APP_PORT;
const localPort = useLocalUrl ? `:${runtimeEnv.LOCAL_APP_PORT}` : '';
const protocol = useLocalUrl ? 'http' : 'https';
const url = new URL(`${protocol}://${hostname}${localPort}`);

Expand All @@ -241,7 +248,7 @@ export const sendEmailLink = async (
url.searchParams.append('type', scope);

const inviter = await client.request<any>(GetUser, {
userId: params.sender_id
userId: params.sender_id,
});
inviterName = inviter?.users?.nodes?.[0]?.displayName;

Expand Down Expand Up @@ -309,29 +316,29 @@ export const sendEmailLink = async (
paddingLeft: '0px',
paddingRight: '0px',
paddingBottom: '0px',
paddingTop: '0'
}
paddingTop: '0',
},
});

if (isDryRun) {
logger.info('DRY RUN email (skipping send)', {
email_type: params.email_type,
email: params.email,
subject,
link
link,
});
} else {
const sendEmail = useSmtp ? sendSmtp : sendPostmaster;
await sendEmail({
to: params.email,
subject,
html
html,
});
}

return {
complete: true,
...(isDryRun ? { dryRun: true } : null)
...(isDryRun ? { dryRun: true } : null),
};
};

Expand All @@ -341,17 +348,21 @@ app.post('/', async (req: any, res: any, next: any) => {
const params = (req.body || {}) as SendEmailParams;

const databaseId =
req.get('X-Database-Id') || req.get('x-database-id') || process.env.DEFAULT_DATABASE_ID;
req.get('X-Database-Id') ||
req.get('x-database-id') ||
runtimeEnv.DEFAULT_DATABASE_ID;
if (!databaseId) {
return res.status(400).json({ error: 'Missing X-Database-Id header or DEFAULT_DATABASE_ID' });
return res
.status(400)
.json({ error: 'Missing X-Database-Id header or DEFAULT_DATABASE_ID' });
}

const graphqlUrl = getRequiredEnv('GRAPHQL_URL');
const metaGraphqlUrl = process.env.META_GRAPHQL_URL || graphqlUrl;
const metaGraphqlUrl = runtimeEnv.META_GRAPHQL_URL || graphqlUrl;

// 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;
const apiName = runtimeEnv.GRAPHQL_API_NAME;
const schemata = runtimeEnv.GRAPHQL_SCHEMATA;

// For GetUser query - needs tenant API access via X-Api-Name or X-Schemata
const client = createGraphQLClient(graphqlUrl, {
Expand All @@ -371,12 +382,14 @@ app.post('/', async (req: any, res: any, next: any) => {
const result = await sendEmailLink(params, {
client,
meta,
databaseId
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}` });
return res
.status(400)
.json({ error: `Missing required field: ${result.missing}` });
}

res.status(200).json(result);
Expand All @@ -388,22 +401,23 @@ app.post('/', async (req: any, res: any, next: any) => {
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);
if (typeof require !== 'undefined' && require.main === module) {
const port = Number(runtimeEnv.PORT ?? 8080);

// Log startup configuration (non-sensitive values only - no API keys or tokens)
logger.info('[send-verification-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',
graphqlUrl: runtimeEnv.GRAPHQL_URL || 'not set',
metaGraphqlUrl:
runtimeEnv.META_GRAPHQL_URL || runtimeEnv.GRAPHQL_URL || 'not set',
apiName: runtimeEnv.GRAPHQL_API_NAME || 'not set',
defaultDatabaseId: runtimeEnv.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
mailgunDomain: runtimeEnv.MAILGUN_DOMAIN || 'not set',
mailgunFrom: runtimeEnv.MAILGUN_FROM || 'not set',
localAppPort: runtimeEnv.LOCAL_APP_PORT || 'not set',
hasAuthToken: !!runtimeEnv.GRAPHQL_AUTH_TOKEN,
});

// @constructive-io/knative-job-fn exposes a .listen method that delegates to the Express app
Expand Down
Loading