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:
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user