Build the agent-native compute CRM platform
CI / verify (push) Successful in 3m6s

This commit is contained in:
2026-08-13 01:39:01 -07:00
parent bfd2f8d95a
commit 853bde2265
160 changed files with 61812 additions and 483 deletions
+43
View File
@@ -0,0 +1,43 @@
/**
* Authentication-provider boundary.
*
* Providers prove an external identity. They do not decide whether that
* identity belongs to PIG; workspace membership remains a database decision
* in the authenticator and signup route.
*/
import { createRemoteJWKSet, jwtVerify } from 'jose';
import type { Config } from './config';
export interface VerifiedIdentity {
subject: string;
email?: string;
}
export interface AuthProvider {
verifyAccessToken(token: string): Promise<VerifiedIdentity>;
}
export function createSupabaseAuthProvider(supabaseUrl: string): AuthProvider {
const issuer = `${supabaseUrl}/auth/v1`;
// `jose` fetches lazily and caches this set, including safe key rotation.
// Sharing one provider instance avoids a remote lookup path per handler.
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
return {
async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
const { payload } = await jwtVerify(token, jwks, { issuer });
if (!payload.sub) throw new Error('token has no subject');
return {
subject: payload.sub,
email: typeof payload.email === 'string' ? payload.email : undefined,
};
},
};
}
export function createConfiguredAuthProvider(
config: Pick<Config, 'SUPABASE_URL'>,
): AuthProvider | null {
return config.SUPABASE_URL ? createSupabaseAuthProvider(config.SUPABASE_URL) : null;
}
+70 -32
View File
@@ -4,9 +4,8 @@
* The distinction is the whole point of this file, and it is the thing most
* likely to be got wrong by someone extending PIG later:
*
* **Authentication** answers "who is this?" and is delegated to Supabase.
* PIG verifies the JWT against the project's JWKS. It stores no passwords
* and issues no sessions of its own.
* **Authentication** answers "who is this?" and is delegated to an identity
* provider. PIG stores no passwords and issues no sessions of its own.
*
* **Authorization** answers "may they use PIG?" and is answered ONLY by a row
* in PIG's `users` table.
@@ -20,13 +19,21 @@
* A token with no matching PIG user gets 403 with `needs_profile`, which the
* front end turns into the invite-redemption screen.
*/
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { eq } from 'drizzle-orm';
import type { Database } from '@pig/db';
import { apiKeys, teamMemberships, users } from '@pig/db';
import type { Team, TeamRole } from '@pig/core';
import {
permissionGranted,
resolvePermissionGrants,
type Capability,
type PermissionGrant,
type Team,
type TeamCapability,
type TeamRole,
} from '@pig/core';
import { createHash, timingSafeEqual } from 'node:crypto';
import type { Config } from './config';
import type { AuthProvider } from './auth-provider';
export interface Principal {
userId: string;
@@ -51,13 +58,11 @@ export class AuthError extends Error {
}
}
export function createAuthenticator(config: Config, db: Database) {
// The JWKS is fetched lazily and cached by `jose`, which also handles key
// rotation. Building it once avoids a fetch per request.
const jwks = config.SUPABASE_URL
? createRemoteJWKSet(new URL(`${config.SUPABASE_URL}/auth/v1/.well-known/jwks.json`))
: null;
export function createAuthenticator(
config: Config,
db: Database,
authProvider: AuthProvider | null,
) {
async function loadPrincipal(
userId: string,
via: Principal['via'],
@@ -95,13 +100,21 @@ export function createAuthenticator(config: Config, db: Database) {
* Accepts either a Supabase JWT or a PIG API key, both in the
* Authorization header. API keys exist so that an agent acting for a person
* is a distinct principal from that person — separately revocable, with its
* own audit trail and its own scopes.
* own audit trail and its own scopes.
*/
async authenticate(header: string | undefined): Promise<Principal> {
const token = header?.startsWith('Bearer ') ? header.slice(7).trim() : null;
// An explicit PIG key must be honoured even in development. Otherwise a
// revoked or malformed key silently becomes the development user, which
// makes local integration tests pass without testing the credential at
// all and hides the exact failures developers need to see.
if (token?.startsWith('pig_')) return authenticateApiKey(token);
// Development escape hatch. Guarded three ways, and `loadConfig` refuses
// to start in production without Supabase, so this cannot leak into a
// real deployment.
if (!config.SUPABASE_URL && !config.isProduction) {
// to start in production without identity configuration, so this cannot
// leak into a real deployment.
if (!authProvider && !config.isProduction) {
const [devUser] = await db.select().from(users).limit(1);
if (!devUser) {
throw new AuthError(
@@ -116,22 +129,14 @@ export function createAuthenticator(config: Config, db: Database) {
if (!header?.startsWith('Bearer ')) {
throw new AuthError('Missing bearer token.', 401, 'no_token');
}
const token = header.slice(7).trim();
// PIG-issued API keys carry a recognisable prefix, so we can route
// without attempting an expensive and pointless JWT verification.
if (token.startsWith('pig_')) return authenticateApiKey(token);
if (!jwks) throw new AuthError('Authentication is not configured.', 401, 'no_jwks');
if (!authProvider) {
throw new AuthError('Authentication is not configured.', 401, 'no_jwks');
}
let subject: string;
try {
const { payload } = await jwtVerify(token, jwks, {
// Supabase signs with the project URL as issuer.
issuer: `${config.SUPABASE_URL}/auth/v1`,
});
if (!payload.sub) throw new Error('token has no subject');
subject = payload.sub;
subject = (await authProvider.verifyAccessToken(token!)).subject;
} catch {
// Deliberately opaque: distinguishing "expired" from "malformed" from
// "wrong issuer" tells an attacker which knob to turn.
@@ -167,10 +172,7 @@ export function createAuthenticator(config: Config, db: Database) {
.limit(1);
if (!record) throw new AuthError('Unknown API key.', 401, 'invalid_key');
if (record.revokedAt) throw new AuthError('This API key was revoked.', 401, 'revoked_key');
if (record.expiresAt && record.expiresAt < new Date()) {
throw new AuthError('This API key has expired.', 401, 'expired_key');
}
assertApiKeyActive(record);
// Best-effort last-used stamp. Never block the request on it: a failed
// bookkeeping write must not deny access.
@@ -187,6 +189,16 @@ export function createAuthenticator(config: Config, db: Database) {
}
}
export function assertApiKeyActive(
record: { revokedAt: Date | null; expiresAt: Date | null },
now = new Date(),
): void {
if (record.revokedAt) throw new AuthError('This API key was revoked.', 401, 'revoked_key');
if (record.expiresAt && record.expiresAt < now) {
throw new AuthError('This API key has expired.', 401, 'expired_key');
}
}
/**
* Hash an API key for storage and lookup.
*
@@ -227,3 +239,29 @@ export function requireScope(principal: Principal, scope: string): void {
if (principal.scopes.includes(scope)) return;
throw new AuthError(`This credential lacks the '${scope}' scope.`, 403, 'insufficient_scope');
}
/** Effective grants include credential scope, not merely the owner's roles. */
export function effectivePermissions(principal: Principal): PermissionGrant[] {
if (!principal.scopes.includes('write')) return [];
return resolvePermissionGrants(principal);
}
export function requireCapability(principal: Principal, capability: Capability): void;
export function requireCapability(
principal: Principal,
capability: TeamCapability,
team: Team,
): void;
export function requireCapability(
principal: Principal,
capability: Capability,
team?: Team,
): void {
requireScope(principal, 'write');
if (permissionGranted(resolvePermissionGrants(principal), capability, team)) return;
throw new AuthError(
`This principal lacks the '${capability}' capability${team ? ` for ${team}` : ''}.`,
403,
'insufficient_permission',
);
}
+119 -4
View File
@@ -26,6 +26,9 @@ const envBoolean = (defaultValue: boolean) =>
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.'),
@@ -45,16 +48,126 @@ const schema = z.object({
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('claude-sonnet-5'),
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.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;
@@ -108,8 +221,10 @@ function warnOnFootguns(config: Config): void {
warn('PRIME_SYNC_ENABLED is on but PRIME_API_KEY is unset — sync will not run.');
}
if (config.PIGGY_ENABLED && !config.ANTHROPIC_API_KEY) {
warn('PIGGY_ENABLED is on but ANTHROPIC_API_KEY is unset — the agent will idle.');
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) {
+194
View File
@@ -0,0 +1,194 @@
import type { ActivityType, GlobalCapability, Team, TeamCapability } from '@pig/core';
import type { Database } from '@pig/db';
import { activities } from '@pig/db';
import type { Context, Handler } from 'hono';
import type { ZodIssue, ZodTypeAny, infer as Infer } from 'zod';
import { requireCapability, type Principal } from './auth';
export type ApiEnv = { Variables: { principal: Principal } };
export interface ApiErrorEnvelope {
error: string;
code: string;
issues?: ZodIssue[];
}
export function apiError(
code: string,
error: string,
issues?: ZodIssue[],
): ApiErrorEnvelope {
return issues ? { error, code, issues } : { error, code };
}
export class MutationError extends Error {
constructor(
readonly code: string,
message: string,
readonly status: 400 | 404 | 409 | 502 | 503,
readonly issues?: ZodIssue[],
) {
super(message);
this.name = 'MutationError';
}
static notFound(resource: string): MutationError {
return new MutationError('not_found', `${resource} not found.`, 404);
}
}
type PermissionRequirement =
| { capability: GlobalCapability; team?: never }
| { capability: TeamCapability; team: Team }
| { authorize(principal: Principal): void };
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0];
export interface MutationActivity {
type: ActivityType;
subject: string;
body?: string;
accountId?: string;
contactId?: string;
demandDealId?: string;
supplyDealId?: string;
meta?: Record<string, unknown>;
}
export interface MutationResult<Result> {
data: Result;
activity: MutationActivity;
}
interface MutationContext<Input> {
input: Input;
principal: Principal;
params: Readonly<Record<string, string>>;
tx: Transaction;
now: Date;
}
export interface MutationDefinition<Schema extends ZodTypeAny, Result> {
schema: Schema;
permission: PermissionRequirement;
invalidMessage: string;
mutate(context: MutationContext<Infer<Schema>>): Promise<MutationResult<Result>>;
}
function enforcePermission(principal: Principal, permission: PermissionRequirement): void {
if ('authorize' in permission) {
permission.authorize(principal);
return;
}
if (permission.capability === 'settings:admin') {
requireCapability(principal, permission.capability);
return;
}
requireCapability(principal, permission.capability, permission.team);
}
/**
* Runs an authorised, validated write and its audit event atomically.
*
* Permission precedes body parsing so a caller cannot probe validation rules
* for a write they are not allowed to perform. Validation precedes the
* transaction so bad input never consumes a connection or leaves audit noise.
*/
export async function executeMutation<Schema extends ZodTypeAny, Result>(
db: Database,
principal: Principal,
readInput: () => Promise<unknown>,
definition: MutationDefinition<Schema, Result>,
params: Readonly<Record<string, string>> = {},
): Promise<Result> {
enforcePermission(principal, definition.permission);
let rawInput: unknown;
try {
rawInput = await readInput();
} catch {
throw new MutationError('invalid_json', 'Request body must be valid JSON.', 400);
}
const parsed = definition.schema.safeParse(rawInput);
if (!parsed.success) {
throw new MutationError(
'invalid_request',
definition.invalidMessage,
400,
parsed.error.issues,
);
}
return db.transaction(async (tx) => {
const now = new Date();
const context: MutationContext<Infer<Schema>> = {
input: parsed.data,
principal,
params,
tx,
now,
};
const result = await definition.mutate(context);
await tx.insert(activities).values({
...result.activity,
actorUserId: principal.userId,
actorAgent: principal.via === 'api_key' ? 'agent' : null,
source: principal.via === 'api_key' ? 'agent' : 'manual',
occurredAt: now,
});
return result.data;
});
}
export function mutation<Schema extends ZodTypeAny, Result>(
db: Database,
definition: MutationDefinition<Schema, Result>,
): Handler<ApiEnv> {
return async (c: Context<ApiEnv>) => {
try {
const result = await executeMutation(
db,
c.get('principal'),
() => c.req.json(),
definition,
c.req.param(),
);
return c.json(result);
} catch (error) {
if (error instanceof MutationError) {
return c.json(apiError(error.code, error.message, error.issues), error.status);
}
throw error;
}
};
}
/**
* DELETE-style writes still use the mutation convention without making clients
* send a meaningless JSON body. The empty input is deliberate: authorization
* still runs first, and route parameters remain request-local.
*/
export function bodylessMutation<Schema extends ZodTypeAny, Result>(
db: Database,
definition: MutationDefinition<Schema, Result>,
): Handler<ApiEnv> {
return async (c: Context<ApiEnv>) => {
try {
const result = await executeMutation(
db,
c.get('principal'),
async () => ({}),
definition,
c.req.param(),
);
return c.json(result);
} catch (error) {
if (error instanceof MutationError) {
return c.json(apiError(error.code, error.message, error.issues), error.status);
}
throw error;
}
};
}
+75
View File
@@ -0,0 +1,75 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const DEFAULT_SECRET_PURPOSE = 'platform-settings:prime-api-key';
function additionalAuthenticatedData(purpose: string): Buffer {
return Buffer.from(`pig:${purpose}:v1`);
}
export class SecretConfigurationError extends Error {
constructor(message: string) {
super(message);
this.name = 'SecretConfigurationError';
}
}
function masterKey(value: string | undefined): Buffer {
if (!value) {
throw new SecretConfigurationError(
'PIG_SETTINGS_ENCRYPTION_KEY is required before credentials can be stored.',
);
}
const key = Buffer.from(value, 'base64');
if (key.length !== 32 || key.toString('base64').replace(/=+$/, '') !== value.replace(/=+$/, '')) {
throw new SecretConfigurationError(
'PIG_SETTINGS_ENCRYPTION_KEY must be a base64-encoded 32-byte key.',
);
}
return key;
}
export function encryptionReady(value: string | undefined): boolean {
try {
masterKey(value);
return true;
} catch {
return false;
}
}
export function encryptSecret(
plaintext: string,
keyValue: string | undefined,
purpose = DEFAULT_SECRET_PURPOSE,
): string {
const key = masterKey(keyValue);
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
cipher.setAAD(additionalAuthenticatedData(purpose));
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return ['v1', iv.toString('base64url'), tag.toString('base64url'), ciphertext.toString('base64url')].join('.');
}
export function decryptSecret(
envelope: string,
keyValue: string | undefined,
purpose = DEFAULT_SECRET_PURPOSE,
): string {
const [version, ivValue, tagValue, ciphertextValue, extra] = envelope.split('.');
if (version !== 'v1' || !ivValue || !tagValue || !ciphertextValue || extra) {
throw new SecretConfigurationError('Stored credential has an unsupported format.');
}
try {
const decipher = createDecipheriv('aes-256-gcm', masterKey(keyValue), Buffer.from(ivValue, 'base64url'));
decipher.setAAD(additionalAuthenticatedData(purpose));
decipher.setAuthTag(Buffer.from(tagValue, 'base64url'));
return Buffer.concat([
decipher.update(Buffer.from(ciphertextValue, 'base64url')),
decipher.final(),
]).toString('utf8');
} catch (error) {
if (error instanceof SecretConfigurationError) throw error;
throw new SecretConfigurationError('Stored credential could not be decrypted.');
}
}