c821b2ca07
CI / verify (push) Successful in 2m55s
The seam existed with only a Supabase implementation, so an on-prem deployment had no way to authenticate. A customer running PIG inside their own network already has Okta, Entra, Keycloak, Auth0 or Google Workspace; asking them to stand up a second identity system is a serious adoption tax and in a regulated environment usually refused outright. Setting PIG_OIDC_ISSUER is normally the whole configuration — the JWKS is discovered from the issuer's well-known document. PIG_OIDC_JWKS_URI skips discovery entirely for an air-gapped network. OIDC takes precedence over Supabase so an on-prem install can leave the hosted values in its environment file without them quietly taking over. Three decisions worth stating: Discovery is resolved lazily and the FAILURE is not cached. Doing it per request would put the customer's identity provider on the critical path of every API call; doing it eagerly at boot would mean their IdP rebooting takes the CRM down with it. So it happens on first use and retries on the next request. The audience check is optional but warned about loudly. Without it, a token the provider issued for ANY other application in the same tenant verifies here — a token minted for an unrelated internal tool would be accepted as a PIG session. It cannot be mandatory because some providers legitimately issue single-audience tokens. Email falls back through email, preferred_username and upn, because providers disagree, but a preferred_username without an "@" is ignored — PIG keys membership on the address, and a bare username must never become an account identity. Also fixed a warning that claimed "authentication is DISABLED" on a correctly configured OIDC deployment. That is worse than silence: an operator who reads it on a secure install learns to ignore the warnings. The dev bypass itself was already correct — it keys on the resolved provider rather than on Supabase. 18 new tests, most of them about what the provider must REFUSE: a foreign signing key, a foreign issuer, a token for a different application, an expired token, a token with no subject, and a discovery outage that must not become permanent. Keys are generated per test and the JWKS is served locally, so they run offline. Verified: production refuses to start with neither provider, starts with OIDC alone, enforces 401 on an unauthenticated request, and warns only about the genuinely missing admin list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
285 lines
11 KiB
TypeScript
285 lines
11 KiB
TypeScript
/**
|
|
* Configuration, read once at boot and validated loudly.
|
|
*
|
|
* A misconfigured deployment should fail to start with a clear message rather
|
|
* than start successfully and behave subtly wrong. The warnings below are the
|
|
* cases where PIG *can* run but an operator almost certainly did not intend
|
|
* the resulting behaviour.
|
|
*/
|
|
import { z } from 'zod';
|
|
|
|
/**
|
|
* Parse a boolean from an environment variable.
|
|
*
|
|
* NOT `z.coerce.boolean()`, which calls `Boolean(value)` — so the string
|
|
* `"false"` becomes `true`, along with `"0"`, `"no"` and `"off"`. Every
|
|
* feature flag set to `false` would silently be on, which is exactly the
|
|
* class of bug that gets discovered in production by reading a startup
|
|
* warning that should not have appeared.
|
|
*/
|
|
const envBoolean = (defaultValue: boolean) =>
|
|
z
|
|
.string()
|
|
.optional()
|
|
.transform((value) => {
|
|
if (value == null || value.trim() === '') return defaultValue;
|
|
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
|
|
});
|
|
|
|
const optionalEnvString = (value: unknown) =>
|
|
typeof value === 'string' && value.trim() === '' ? undefined : value;
|
|
|
|
const schema = z.object({
|
|
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'),
|
|
|
|
SUPABASE_URL: z.string().url().optional(),
|
|
|
|
/*
|
|
* OIDC — the on-premises path.
|
|
*
|
|
* A customer running PIG inside their own network already has an identity
|
|
* provider. Setting PIG_OIDC_ISSUER switches authentication to it and takes
|
|
* precedence over any Supabase values left in the environment file.
|
|
*/
|
|
PIG_OIDC_ISSUER: z.string().url().optional(),
|
|
/** Optional. Discovered from the issuer when omitted. */
|
|
PIG_OIDC_JWKS_URI: z.string().url().optional(),
|
|
/** Strongly recommended — see the boot warning. */
|
|
PIG_OIDC_AUDIENCE: z.string().optional(),
|
|
/** Comma-separated, in preference order. Defaults cover most providers. */
|
|
PIG_OIDC_EMAIL_CLAIMS: z.string().optional(),
|
|
SUPABASE_ANON_KEY: z.string().optional(),
|
|
SUPABASE_SERVICE_KEY: z.string().optional(),
|
|
|
|
PIG_PORT: z.coerce.number().int().positive().default(8920),
|
|
PIG_PUBLIC_URL: z.string().default('http://localhost:8920'),
|
|
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
|
|
|
PIG_ADMIN_EMAILS: z.string().default(''),
|
|
PIG_INVITE_CODE: z.string().optional(),
|
|
|
|
PRIME_API_KEY: z.string().optional(),
|
|
PRIME_API_BASE: z.string().default('https://api.primeintellect.ai'),
|
|
PRIME_SYNC_ENABLED: envBoolean(false),
|
|
PRIME_SYNC_INTERVAL_MINUTES: z.coerce.number().int().positive().default(30),
|
|
|
|
/** Base64-encoded 32-byte key. Secrets written in the admin UI require it. */
|
|
PIG_SETTINGS_ENCRYPTION_KEY: z.string().optional(),
|
|
|
|
PIGGY_ENABLED: envBoolean(false),
|
|
ANTHROPIC_API_KEY: z.string().optional(),
|
|
PIGGY_MODEL: z.string().default('nvidia/nemotron-3-nano-30b-a3b'),
|
|
PIGGY_INFERENCE_BASE: z.string().url().default('https://api.pinference.ai/api/v1'),
|
|
PIGGY_LEASE_SECONDS: z.coerce.number().int().positive().default(300),
|
|
PIGGY_INTERNAL_URL: z.preprocess(
|
|
(value) => (value === '' ? undefined : value),
|
|
z.string().url().optional(),
|
|
),
|
|
PIGGY_INTERNAL_TOKEN: z.preprocess(
|
|
(value) => (value === '' ? undefined : value),
|
|
z.string().min(32).optional(),
|
|
),
|
|
|
|
SLACK_BOT_TOKEN: z.string().optional(),
|
|
SLACK_SIGNING_SECRET: z.string().optional(),
|
|
BUZZ_RELAY_URL: z.preprocess(optionalEnvString, z.string().url().optional()),
|
|
BUZZ_PRIVATE_KEY: z.preprocess(optionalEnvString, z.string().min(1).optional()),
|
|
BUZZ_AUTH_TAG: z.preprocess(optionalEnvString, z.string().min(1).optional()),
|
|
|
|
NOTION_CLIENT_ID: z.preprocess(optionalEnvString, z.string().min(1).optional()),
|
|
NOTION_CLIENT_SECRET: z.preprocess(optionalEnvString, z.string().min(1).optional()),
|
|
NOTION_REDIRECT_URI: z.preprocess(optionalEnvString, z.string().url().optional()),
|
|
GOOGLE_CLIENT_ID: z.preprocess(optionalEnvString, z.string().min(1).optional()),
|
|
GOOGLE_CLIENT_SECRET: z.preprocess(optionalEnvString, z.string().min(1).optional()),
|
|
GOOGLE_REDIRECT_URI: z.preprocess(optionalEnvString, z.string().url().optional()),
|
|
}).superRefine((value, context) => {
|
|
const buzzConfigured = Boolean(
|
|
value.BUZZ_RELAY_URL || value.BUZZ_PRIVATE_KEY || value.BUZZ_AUTH_TAG,
|
|
);
|
|
if (buzzConfigured && !value.BUZZ_RELAY_URL) {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['BUZZ_RELAY_URL'],
|
|
message: 'BUZZ_RELAY_URL is required when Buzz delivery is configured.',
|
|
});
|
|
}
|
|
if (buzzConfigured && !value.BUZZ_PRIVATE_KEY) {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['BUZZ_PRIVATE_KEY'],
|
|
message: 'BUZZ_PRIVATE_KEY is required when Buzz delivery is configured.',
|
|
});
|
|
}
|
|
|
|
const notionConfigured = Boolean(
|
|
value.NOTION_CLIENT_ID || value.NOTION_CLIENT_SECRET || value.NOTION_REDIRECT_URI,
|
|
);
|
|
for (const key of ['NOTION_CLIENT_ID', 'NOTION_CLIENT_SECRET', 'NOTION_REDIRECT_URI'] as const) {
|
|
if (notionConfigured && !value[key]) {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: [key],
|
|
message: `${key} is required when Notion import is configured.`,
|
|
});
|
|
}
|
|
}
|
|
if (notionConfigured && !hasValidEncryptionKey(value.PIG_SETTINGS_ENCRYPTION_KEY)) {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['PIG_SETTINGS_ENCRYPTION_KEY'],
|
|
message: 'A base64-encoded 32-byte PIG_SETTINGS_ENCRYPTION_KEY is required for Notion OAuth.',
|
|
});
|
|
}
|
|
|
|
const googleConfigured = Boolean(
|
|
value.GOOGLE_CLIENT_ID || value.GOOGLE_CLIENT_SECRET || value.GOOGLE_REDIRECT_URI,
|
|
);
|
|
for (const key of ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'GOOGLE_REDIRECT_URI'] as const) {
|
|
if (googleConfigured && !value[key]) {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: [key],
|
|
message: `${key} is required when Google Sheets import is configured.`,
|
|
});
|
|
}
|
|
}
|
|
if (googleConfigured && !hasValidEncryptionKey(value.PIG_SETTINGS_ENCRYPTION_KEY)) {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['PIG_SETTINGS_ENCRYPTION_KEY'],
|
|
message: 'A base64-encoded 32-byte PIG_SETTINGS_ENCRYPTION_KEY is required for Google OAuth.',
|
|
});
|
|
}
|
|
if (value.GOOGLE_REDIRECT_URI) {
|
|
try {
|
|
const redirect = new URL(value.GOOGLE_REDIRECT_URI);
|
|
const publicUrl = new URL(value.PIG_PUBLIC_URL);
|
|
if (
|
|
redirect.origin !== publicUrl.origin
|
|
|| redirect.pathname !== '/oauth/google/callback'
|
|
|| redirect.search
|
|
|| redirect.hash
|
|
) {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['GOOGLE_REDIRECT_URI'],
|
|
message: 'GOOGLE_REDIRECT_URI must use the PIG_PUBLIC_URL origin and exact /oauth/google/callback path.',
|
|
});
|
|
}
|
|
} catch {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['GOOGLE_REDIRECT_URI'],
|
|
message: 'GOOGLE_REDIRECT_URI must use the PIG_PUBLIC_URL origin and exact /oauth/google/callback path.',
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
function hasValidEncryptionKey(value: string | undefined): boolean {
|
|
if (!value) return false;
|
|
const decoded = Buffer.from(value, 'base64');
|
|
return decoded.length === 32
|
|
&& decoded.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '');
|
|
}
|
|
|
|
export type Config = z.infer<typeof schema> & {
|
|
adminEmails: string[];
|
|
isProduction: boolean;
|
|
};
|
|
|
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
|
const parsed = schema.safeParse(env);
|
|
if (!parsed.success) {
|
|
const issues = parsed.error.issues.map((i) => ` ${i.path.join('.')}: ${i.message}`);
|
|
throw new Error(`Invalid configuration:\n${issues.join('\n')}`);
|
|
}
|
|
|
|
const adminEmails = parsed.data.PIG_ADMIN_EMAILS.split(',')
|
|
.map((e) => e.trim().toLowerCase())
|
|
.filter(Boolean);
|
|
|
|
const config: Config = {
|
|
...parsed.data,
|
|
adminEmails,
|
|
isProduction: parsed.data.NODE_ENV === 'production',
|
|
};
|
|
|
|
warnOnFootguns(config);
|
|
return config;
|
|
}
|
|
|
|
function warnOnFootguns(config: Config): void {
|
|
const warn = (message: string) => console.warn(`[pig] WARNING: ${message}`);
|
|
|
|
if (config.adminEmails.length === 0) {
|
|
// Safe default, but worth saying out loud: with no admins nobody can
|
|
// manage invites or settings through the UI.
|
|
warn('PIG_ADMIN_EMAILS is empty — no user will have platform-admin rights.');
|
|
}
|
|
|
|
// Keyed on BOTH providers, not just Supabase. An OIDC deployment has
|
|
// authentication and this warning previously claimed it did not — which is
|
|
// worse than saying nothing, because an operator reading "authentication is
|
|
// DISABLED" on a correctly secured install learns to ignore the warnings.
|
|
if (!config.SUPABASE_URL && !config.PIG_OIDC_ISSUER) {
|
|
warn(
|
|
'No identity provider is configured (SUPABASE_URL or PIG_OIDC_ISSUER) — ' +
|
|
'authentication is DISABLED and every request runs as the development ' +
|
|
'user. Never do this in production.',
|
|
);
|
|
}
|
|
|
|
if (config.PIG_OIDC_ISSUER && config.SUPABASE_URL) {
|
|
warn(
|
|
'Both PIG_OIDC_ISSUER and SUPABASE_URL are set — OIDC takes precedence and ' +
|
|
'Supabase will not be used for authentication.',
|
|
);
|
|
}
|
|
|
|
if (config.PIG_OIDC_ISSUER && !config.PIG_OIDC_AUDIENCE) {
|
|
// Not fatal, because some providers issue single-audience tokens where it
|
|
// adds nothing — but on a shared corporate tenant this is the difference
|
|
// between "a token for PIG" and "a token for anything in the company".
|
|
warn(
|
|
'PIG_OIDC_AUDIENCE is not set. Any token your identity provider issued for ' +
|
|
'ANY application in the same tenant will be accepted here. Set it unless ' +
|
|
'you are certain that is safe.',
|
|
);
|
|
}
|
|
|
|
if (config.PIG_OIDC_ISSUER && config.SUPABASE_SERVICE_KEY) {
|
|
warn(
|
|
'SUPABASE_SERVICE_KEY is set while running on OIDC. Self-registration mints ' +
|
|
'Supabase accounts, which an OIDC deployment does not use — unset it and ' +
|
|
'provision users through your identity provider instead.',
|
|
);
|
|
}
|
|
|
|
if (config.isProduction && !config.SUPABASE_URL && !config.PIG_OIDC_ISSUER) {
|
|
throw new Error(
|
|
'Refusing to start: NODE_ENV=production with neither SUPABASE_URL nor ' +
|
|
'PIG_OIDC_ISSUER would serve the entire CRM unauthenticated.',
|
|
);
|
|
}
|
|
|
|
if (config.PRIME_SYNC_ENABLED && !config.PRIME_API_KEY) {
|
|
warn('PRIME_SYNC_ENABLED is on but PRIME_API_KEY is unset — sync will not run.');
|
|
}
|
|
|
|
if (config.PIGGY_ENABLED && (!config.PIGGY_INTERNAL_URL || !config.PIGGY_INTERNAL_TOKEN)) {
|
|
warn(
|
|
'PIGGY_ENABLED is on but the internal URL or token is unset — interactive chat will be unavailable.',
|
|
);
|
|
}
|
|
|
|
if (config.SUPABASE_SERVICE_KEY) {
|
|
// Present legitimately for admin provisioning, but it is the most powerful
|
|
// credential in the deployment and most installs do not need it.
|
|
warn(
|
|
'SUPABASE_SERVICE_KEY is set. It is only needed for administrative user ' +
|
|
'provisioning; unset it if you are not using that.',
|
|
);
|
|
}
|
|
}
|