Add Prime Intellect client, API, and MCP server

packages/prime — a hand-written typed client, because the first-party SDK is
Python only. Deliberately narrow: PIG reads availability and nothing else, and
the key it holds should be scoped so it could not provision even if the code
tried. Rate limits are undocumented upstream, so it backs off empirically with
full jitter and honours Retry-After. Unknown fields survive in `raw` rather
than being dropped.

apps/api — Hono, with authentication and authorization kept firmly apart. A
verified JWT proves someone has an account in the identity project, which may
be shared with other applications; it does NOT prove they belong here. Access
requires a row in PIG's own users table, and a token without one gets 403
needs_profile rather than entry.

The capacity service is the business logic: availability counts sold and held
separately, so a live hold removes inventory from everyone else's availability
without inflating utilisation. Expired holds are ignored at read time, so the
numbers stay right even when the sweeper is behind. Matching treats
interconnect as a hard filter and excludes Unknown as well as Ethernet —
unverified is not the same as adequate.

apps/mcp — nine tools over stdio, so a team member drives PIG from Claude
Code, Codex, prime-agent, or a Buzz agent. It holds an API key and calls the
same HTTP API the browser does, with no database credentials, so an agent can
never reach further than the person it acts for. Results are formatted as
prose rather than raw JSON.

Theme preferences live in the database rather than localStorage, so a chosen
accent follows someone from laptop to phone. Status colours stay independent
of the accent: if "at risk" re-tinted to whatever a user picked, the signal
would be gone.

Note on the SDK import: its package exports use a `./*` wildcard whose types
entry resolves server/mcp.js to server/mcp.js.d.ts, which does not exist. The
runtime specifier must keep the .js suffix, so the types are mapped via
tsconfig paths rather than by writing an import that would fail at runtime.

Verified: all five packages typecheck; the MCP server constructs and registers
its tools.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-12 19:02:45 -07:00
parent d36762f264
commit 7aeec0c632
21 changed files with 3997 additions and 2 deletions
+229
View File
@@ -0,0 +1,229 @@
/**
* Authentication and authorization.
*
* 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.
*
* **Authorization** answers "may they use PIG?" and is answered ONLY by a row
* in PIG's `users` table.
*
* These must stay separate because the Supabase project may be shared with
* other applications. A valid token proves someone has an account *somewhere in
* that project* — not that they belong here. Treating a verified token as
* sufficient would silently grant every user of every sibling application full
* access to the CRM.
*
* 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 { createHash, timingSafeEqual } from 'node:crypto';
import type { Config } from './config';
export interface Principal {
userId: string;
email: string;
name: string;
isPlatformAdmin: boolean;
teams: { team: Team; role: TeamRole }[];
/** How this request authenticated. Agents get their own audit trail. */
via: 'jwt' | 'api_key' | 'development';
apiKeyId?: string;
scopes: string[];
}
export class AuthError extends Error {
constructor(
message: string,
readonly status: 401 | 403,
readonly code: string,
) {
super(message);
this.name = 'AuthError';
}
}
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;
async function loadPrincipal(
userId: string,
via: Principal['via'],
extras: { apiKeyId?: string; scopes?: string[] } = {},
): Promise<Principal> {
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (!user) throw new AuthError('No PIG profile for this account.', 403, 'needs_profile');
if (user.deactivatedAt) throw new AuthError('This account is deactivated.', 403, 'deactivated');
const memberships = await db
.select({ team: teamMemberships.team, role: teamMemberships.role })
.from(teamMemberships)
.where(eq(teamMemberships.userId, user.id));
return {
userId: user.id,
email: user.email,
name: user.name,
// Admin rights come from the database, but the environment allowlist can
// grant them too — that is how the first admin exists before anyone has
// been able to log in and promote anybody.
isPlatformAdmin:
user.isPlatformAdmin || config.adminEmails.includes(user.email.toLowerCase()),
teams: memberships as { team: Team; role: TeamRole }[],
via,
apiKeyId: extras.apiKeyId,
scopes: extras.scopes ?? ['read', 'write'],
};
}
return {
/**
* Resolve the principal for a request, or throw.
*
* 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.
*/
async authenticate(header: string | undefined): Promise<Principal> {
// 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) {
const [devUser] = await db.select().from(users).limit(1);
if (!devUser) {
throw new AuthError(
'Auth is disabled and the database has no users. Run `npm run db:seed`.',
403,
'no_dev_user',
);
}
return loadPrincipal(devUser.id, 'development');
}
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');
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;
} catch {
// Deliberately opaque: distinguishing "expired" from "malformed" from
// "wrong issuer" tells an attacker which knob to turn.
throw new AuthError('Invalid or expired token.', 401, 'invalid_token');
}
const [user] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.authSubject, subject))
.limit(1);
if (!user) {
// Authenticated but not authorized — the case that matters when the
// identity provider is shared with another application.
throw new AuthError(
'This account is not a member of this PIG workspace.',
403,
'needs_profile',
);
}
return loadPrincipal(user.id, 'jwt');
},
};
async function authenticateApiKey(token: string): Promise<Principal> {
const hash = hashApiKey(token);
const [record] = await db
.select()
.from(apiKeys)
.where(eq(apiKeys.keyHash, hash))
.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');
}
// Best-effort last-used stamp. Never block the request on it: a failed
// bookkeeping write must not deny access.
void db
.update(apiKeys)
.set({ lastUsedAt: new Date() })
.where(eq(apiKeys.id, record.id))
.catch(() => {});
return loadPrincipal(record.userId, 'api_key', {
apiKeyId: record.id,
scopes: record.scopes,
});
}
}
/**
* Hash an API key for storage and lookup.
*
* SHA-256 without a salt is correct here, unlike for passwords: the key is 256
* bits of machine-generated randomness, so there is no dictionary to attack and
* lookup must be deterministic. What matters is that the plaintext is never
* stored, so a database leak yields no working credentials.
*/
export function hashApiKey(key: string): string {
return createHash('sha256').update(key).digest('hex');
}
/** Constant-time compare, for anywhere a secret is checked directly. */
export function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
// Length alone can leak, so compare hashes of equal length rather than
// returning early on a length mismatch.
const ah = createHash('sha256').update(ab).digest();
const bh = createHash('sha256').update(bb).digest();
return timingSafeEqual(ah, bh);
}
/** Does this principal belong to the team, at or above the given role? */
export function hasTeamAccess(
principal: Principal,
team: Team,
minimumRole: TeamRole = 'member',
): boolean {
if (principal.isPlatformAdmin) return true;
const membership = principal.teams.find((t) => t.team === team);
if (!membership) return false;
const rank: Record<TeamRole, number> = { member: 0, lead: 1, admin: 2 };
return rank[membership.role] >= rank[minimumRole];
}
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');
}
+105
View File
@@ -0,0 +1,105 @@
/**
* 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';
const schema = z.object({
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'),
SUPABASE_URL: z.string().url().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: z.coerce.boolean().default(false),
PRIME_SYNC_INTERVAL_MINUTES: z.coerce.number().int().positive().default(30),
PIGGY_ENABLED: z.coerce.boolean().default(false),
ANTHROPIC_API_KEY: z.string().optional(),
PIGGY_MODEL: z.string().default('claude-sonnet-5'),
PIGGY_LEASE_SECONDS: z.coerce.number().int().positive().default(300),
SLACK_BOT_TOKEN: z.string().optional(),
SLACK_SIGNING_SECRET: z.string().optional(),
BUZZ_RELAY_URL: z.string().optional(),
});
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.');
}
if (!config.SUPABASE_URL) {
warn(
'SUPABASE_URL is not set — authentication is DISABLED and every request ' +
'runs as the development user. Never do this in production.',
);
}
if (config.isProduction && !config.SUPABASE_URL) {
throw new Error(
'Refusing to start: NODE_ENV=production with no SUPABASE_URL 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.ANTHROPIC_API_KEY) {
warn('PIGGY_ENABLED is on but ANTHROPIC_API_KEY is unset — the agent will idle.');
}
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.',
);
}
}