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,535 @@
|
||||
/**
|
||||
* The HTTP application.
|
||||
*
|
||||
* Deliberately thin. Following the rule stated in the README — *intelligence
|
||||
* never lives in the API* — these handlers validate input, check authorization,
|
||||
* call a service, and serialise the result. Research, enrichment, scoring and
|
||||
* matching heuristics live in the service layer or in the agent, never here.
|
||||
*/
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import { and, desc, eq, ilike, isNull, or, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import type { Database } from '@pig/db';
|
||||
import {
|
||||
accounts,
|
||||
activities,
|
||||
agentTasks,
|
||||
allocations,
|
||||
capacityCommitments,
|
||||
contacts,
|
||||
contracts,
|
||||
demandDeals,
|
||||
supplyDeals,
|
||||
teamMemberships,
|
||||
users,
|
||||
} from '@pig/db';
|
||||
import {
|
||||
ACCENTS,
|
||||
ACCOUNT_SIDES,
|
||||
ACTIVITY_TYPES,
|
||||
CUSTOMER_SEGMENTS,
|
||||
DEMAND_STAGES,
|
||||
SUPPLIER_TYPES,
|
||||
SUPPLY_STAGES,
|
||||
TEAMS,
|
||||
THEME_MODES,
|
||||
isValidAccent,
|
||||
isValidThemeMode,
|
||||
} from '@pig/core';
|
||||
import type { Config } from './lib/config';
|
||||
import { AuthError, createAuthenticator, type Principal } from './lib/auth';
|
||||
import { CapacityService } from './services/capacity';
|
||||
|
||||
type Env = { Variables: { principal: Principal } };
|
||||
|
||||
export function createApp(config: Config, db: Database) {
|
||||
const app = new Hono<Env>();
|
||||
const auth = createAuthenticator(config, db);
|
||||
const capacity = new CapacityService(db);
|
||||
|
||||
if (!config.isProduction) app.use('*', logger());
|
||||
|
||||
app.use(
|
||||
'/api/*',
|
||||
cors({
|
||||
// In production the front end is served from the same origin, so no
|
||||
// cross-origin allowance is needed. In development Vite runs separately.
|
||||
origin: config.isProduction ? config.PIG_PUBLIC_URL : ['http://localhost:5173'],
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
/** Liveness. Unauthenticated by design so a load balancer can reach it. */
|
||||
app.get('/api/health', (c) => c.json({ ok: true, service: 'pig', version: '0.1.0' }));
|
||||
|
||||
/**
|
||||
* Public configuration for the front end — what it needs before anyone has
|
||||
* logged in. Contains only values that are safe in a browser: the anon key is
|
||||
* designed to be public, and no secret is exposed here.
|
||||
*/
|
||||
app.get('/api/config', (c) =>
|
||||
c.json({
|
||||
supabaseUrl: config.SUPABASE_URL ?? null,
|
||||
supabaseAnonKey: config.SUPABASE_ANON_KEY ?? null,
|
||||
authDisabled: !config.SUPABASE_URL,
|
||||
inviteRequired: Boolean(config.PIG_INVITE_CODE),
|
||||
accents: ACCENTS.map((a) => ({ key: a.key, label: a.label })),
|
||||
teams: TEAMS,
|
||||
}),
|
||||
);
|
||||
|
||||
// Everything below requires a principal.
|
||||
app.use('/api/*', async (c, next) => {
|
||||
const path = new URL(c.req.url).pathname;
|
||||
if (path === '/api/health' || path === '/api/config' || path === '/api/signup') {
|
||||
return next();
|
||||
}
|
||||
try {
|
||||
c.set('principal', await auth.authenticate(c.req.header('authorization')));
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: error.message, code: error.code }, error.status);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- identity
|
||||
|
||||
app.get('/api/me', (c) => {
|
||||
const p = c.get('principal');
|
||||
return c.json({
|
||||
id: p.userId,
|
||||
email: p.email,
|
||||
name: p.name,
|
||||
isPlatformAdmin: p.isPlatformAdmin,
|
||||
teams: p.teams,
|
||||
via: p.via,
|
||||
});
|
||||
});
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
themeMode: z.enum(THEME_MODES).optional(),
|
||||
accentColor: z.string().refine(isValidAccent, 'Unknown accent').optional(),
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
handle: z.string().min(2).max(40).regex(/^[a-z0-9_-]+$/i).optional(),
|
||||
title: z.string().max(160).optional(),
|
||||
timezone: z.string().max(80).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Appearance and profile preferences.
|
||||
*
|
||||
* Persisted server-side rather than in localStorage so that a person's chosen
|
||||
* theme follows them from laptop to phone — which matters more than it might
|
||||
* seem, because a CRM is genuinely used on both.
|
||||
*/
|
||||
app.patch('/api/me/preferences', async (c) => {
|
||||
const p = c.get('principal');
|
||||
const parsed = preferencesSchema.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: 'Invalid preferences', issues: parsed.error.issues }, 400);
|
||||
}
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set({ ...parsed.data, updatedAt: new Date() })
|
||||
.where(eq(users.id, p.userId))
|
||||
.returning();
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
app.get('/api/me/profile', async (c) => {
|
||||
const p = c.get('principal');
|
||||
const [user] = await db.select().from(users).where(eq(users.id, p.userId)).limit(1);
|
||||
return c.json(user ?? null);
|
||||
});
|
||||
|
||||
app.get('/api/team', async (c) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
name: users.name,
|
||||
email: users.email,
|
||||
handle: users.handle,
|
||||
title: users.title,
|
||||
avatarUrl: users.avatarUrl,
|
||||
team: teamMemberships.team,
|
||||
role: teamMemberships.role,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(teamMemberships, eq(teamMemberships.userId, users.id))
|
||||
.where(isNull(users.deactivatedAt));
|
||||
|
||||
// Collapse the join: one row per person, carrying every team they're on.
|
||||
const byId = new Map<string, Record<string, unknown>>();
|
||||
for (const row of rows) {
|
||||
const existing = byId.get(row.id);
|
||||
const membership = row.team ? { team: row.team, role: row.role } : null;
|
||||
if (existing) {
|
||||
if (membership) (existing.teams as unknown[]).push(membership);
|
||||
} else {
|
||||
byId.set(row.id, {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
handle: row.handle,
|
||||
title: row.title,
|
||||
avatarUrl: row.avatarUrl,
|
||||
teams: membership ? [membership] : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
return c.json([...byId.values()]);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- accounts
|
||||
|
||||
app.get('/api/accounts', async (c) => {
|
||||
const side = c.req.query('side');
|
||||
const q = c.req.query('q');
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(accounts)
|
||||
.where(
|
||||
and(
|
||||
isNull(accounts.archivedAt),
|
||||
side && side !== 'all'
|
||||
? or(eq(accounts.side, side as 'supply'), eq(accounts.side, 'both'))
|
||||
: undefined,
|
||||
q ? ilike(accounts.name, `%${q}%`) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(accounts.lastActivityAt), accounts.name)
|
||||
.limit(200);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
app.get('/api/accounts/:id', async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const [account] = await db.select().from(accounts).where(eq(accounts.id, id)).limit(1);
|
||||
if (!account) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [accountContacts, demand, supply, paperwork, recentActivity] = await Promise.all([
|
||||
db.select().from(contacts).where(eq(contacts.accountId, id)),
|
||||
db.select().from(demandDeals).where(eq(demandDeals.accountId, id)),
|
||||
db.select().from(supplyDeals).where(eq(supplyDeals.accountId, id)),
|
||||
db.select().from(contracts).where(eq(contracts.accountId, id)),
|
||||
db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.accountId, id))
|
||||
.orderBy(desc(activities.occurredAt))
|
||||
.limit(50),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
account,
|
||||
contacts: accountContacts,
|
||||
demandDeals: demand,
|
||||
supplyDeals: supply,
|
||||
contracts: paperwork,
|
||||
activities: recentActivity,
|
||||
});
|
||||
});
|
||||
|
||||
// Enum members are drawn from the ontology rather than retyped, so a value
|
||||
// added there is accepted here without a second edit — and, more importantly,
|
||||
// a value removed there stops validating rather than silently persisting.
|
||||
const accountSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
domain: z.string().max(200).optional(),
|
||||
website: z.string().max(400).optional(),
|
||||
description: z.string().max(4000).optional(),
|
||||
side: z.enum(ACCOUNT_SIDES).default('demand'),
|
||||
supplierType: z.enum(SUPPLIER_TYPES).optional(),
|
||||
customerSegment: z.enum(CUSTOMER_SEGMENTS).optional(),
|
||||
country: z.string().max(100).optional(),
|
||||
region: z.string().max(100).optional(),
|
||||
ultimateParentName: z.string().max(200).optional(),
|
||||
ultimateParentCountry: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
app.post('/api/accounts', async (c) => {
|
||||
const p = c.get('principal');
|
||||
const parsed = accountSchema.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: 'Invalid account', issues: parsed.error.issues }, 400);
|
||||
}
|
||||
const [created] = await db
|
||||
.insert(accounts)
|
||||
.values({
|
||||
...parsed.data,
|
||||
ownerUserId: p.userId,
|
||||
source: 'manual',
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Queue enrichment rather than performing it inline. The API must never
|
||||
// block on a model, and the queue survives the agent being down.
|
||||
if (created) {
|
||||
await db
|
||||
.insert(agentTasks)
|
||||
.values({
|
||||
kind: 'enrich_account',
|
||||
subject: created.id,
|
||||
reason: `New account created by ${p.name}`,
|
||||
requestedByUserId: p.userId,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
return c.json(created, 201);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------- deals
|
||||
|
||||
app.get('/api/deals/demand', async (c) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
deal: demandDeals,
|
||||
accountName: accounts.name,
|
||||
accountDomain: accounts.domain,
|
||||
})
|
||||
.from(demandDeals)
|
||||
.leftJoin(accounts, eq(accounts.id, demandDeals.accountId))
|
||||
.orderBy(desc(demandDeals.updatedAt))
|
||||
.limit(300);
|
||||
return c.json({ stages: DEMAND_STAGES, deals: rows });
|
||||
});
|
||||
|
||||
app.get('/api/deals/supply', async (c) => {
|
||||
const rows = await db
|
||||
.select({
|
||||
deal: supplyDeals,
|
||||
accountName: accounts.name,
|
||||
accountDomain: accounts.domain,
|
||||
})
|
||||
.from(supplyDeals)
|
||||
.leftJoin(accounts, eq(accounts.id, supplyDeals.accountId))
|
||||
.orderBy(desc(supplyDeals.updatedAt))
|
||||
.limit(300);
|
||||
return c.json({ stages: SUPPLY_STAGES, deals: rows });
|
||||
});
|
||||
|
||||
const stageSchema = z.object({ stage: z.string() });
|
||||
|
||||
/**
|
||||
* Stage transitions are recorded as activities, not merely written.
|
||||
*
|
||||
* "When did this move to procurement, and who moved it?" is the question
|
||||
* asked in every pipeline review, and a column that only holds the current
|
||||
* value cannot answer it.
|
||||
*/
|
||||
app.patch('/api/deals/demand/:id/stage', async (c) => {
|
||||
const p = c.get('principal');
|
||||
const id = c.req.param('id');
|
||||
const parsed = stageSchema.safeParse(await c.req.json());
|
||||
if (!parsed.success || !(DEMAND_STAGES as readonly string[]).includes(parsed.data.stage)) {
|
||||
return c.json({ error: 'Unknown stage' }, 400);
|
||||
}
|
||||
|
||||
const [before] = await db.select().from(demandDeals).where(eq(demandDeals.id, id)).limit(1);
|
||||
if (!before) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(demandDeals)
|
||||
.set({
|
||||
stage: parsed.data.stage as 'qualification',
|
||||
stageChangedAt: now,
|
||||
updatedAt: now,
|
||||
lastActivityAt: now,
|
||||
})
|
||||
.where(eq(demandDeals.id, id))
|
||||
.returning();
|
||||
|
||||
await db.insert(activities).values({
|
||||
type: 'stage_change',
|
||||
subject: `${before.stage} → ${parsed.data.stage}`,
|
||||
accountId: before.accountId,
|
||||
demandDealId: id,
|
||||
actorUserId: p.userId,
|
||||
occurredAt: now,
|
||||
meta: { from: before.stage, to: parsed.data.stage },
|
||||
});
|
||||
|
||||
return c.json(updated);
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------- activities
|
||||
|
||||
const activitySchema = z.object({
|
||||
accountId: z.string().uuid().optional(),
|
||||
contactId: z.string().uuid().optional(),
|
||||
demandDealId: z.string().uuid().optional(),
|
||||
supplyDealId: z.string().uuid().optional(),
|
||||
type: z.enum(ACTIVITY_TYPES),
|
||||
subject: z.string().min(1).max(200),
|
||||
body: z.string().max(8000).optional(),
|
||||
occurredAt: z.string().datetime().optional(),
|
||||
externalId: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
app.post('/api/activities', async (c) => {
|
||||
const p = c.get('principal');
|
||||
const parsed = activitySchema.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: 'Invalid activity', issues: parsed.error.issues }, 400);
|
||||
}
|
||||
const { occurredAt, ...rest } = parsed.data;
|
||||
const when = occurredAt ? new Date(occurredAt) : new Date();
|
||||
|
||||
const [created] = await db
|
||||
.insert(activities)
|
||||
.values({
|
||||
...rest,
|
||||
occurredAt: when,
|
||||
actorUserId: p.userId,
|
||||
// An agent acting for someone is recorded as such, so the log
|
||||
// distinguishes what a person did from what was done on their behalf.
|
||||
actorAgent: p.via === 'api_key' ? 'agent' : null,
|
||||
source: p.via === 'api_key' ? 'agent' : 'manual',
|
||||
})
|
||||
// An `externalId` collision means this event was already synced from
|
||||
// Slack or Buzz; silently ignoring the duplicate keeps sync idempotent.
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
if (rest.accountId) {
|
||||
await db
|
||||
.update(accounts)
|
||||
.set({ lastActivityAt: when })
|
||||
.where(eq(accounts.id, rest.accountId));
|
||||
}
|
||||
|
||||
return c.json(created ?? { deduplicated: true }, created ? 201 : 200);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- capacity
|
||||
|
||||
app.get('/api/capacity/availability', async (c) => {
|
||||
const gpuType = c.req.query('gpuType') ?? undefined;
|
||||
return c.json(await capacity.availability({ gpuType }));
|
||||
});
|
||||
|
||||
app.get('/api/capacity/idle', async (c) => {
|
||||
const threshold = Number(c.req.query('threshold') ?? '0.25');
|
||||
return c.json(
|
||||
await capacity.idleCapacity({
|
||||
thresholdPct: Number.isFinite(threshold) ? threshold : 0.25,
|
||||
withinDays: Number(c.req.query('withinDays') ?? '30') || 30,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/api/capacity/margin', async (c) => c.json(await capacity.marginReport()));
|
||||
|
||||
const matchSchema = z.object({
|
||||
gpuType: z.string().optional(),
|
||||
gpuTypeAlternatives: z.array(z.string()).optional(),
|
||||
gpuCount: z.number().int().positive(),
|
||||
totalGpuHours: z.number().positive().optional(),
|
||||
requiresHighSpeedInterconnect: z.boolean().optional(),
|
||||
minSecurityTier: z.enum(['secure_cloud', 'community_cloud']).optional(),
|
||||
startsAt: z.string().datetime().optional(),
|
||||
endsAt: z.string().datetime().optional(),
|
||||
maxPricePerGpuHourCents: z.number().int().positive().optional(),
|
||||
});
|
||||
|
||||
app.post('/api/capacity/match', async (c) => {
|
||||
const parsed = matchSchema.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
return c.json({ error: 'Invalid requirement', issues: parsed.error.issues }, 400);
|
||||
}
|
||||
const { startsAt, endsAt, ...rest } = parsed.data;
|
||||
return c.json(
|
||||
await capacity.match({
|
||||
...rest,
|
||||
startsAt: startsAt ? new Date(startsAt) : undefined,
|
||||
endsAt: endsAt ? new Date(endsAt) : undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.get('/api/inventory', async (c) =>
|
||||
c.json(
|
||||
await capacity.searchInventory({
|
||||
gpuType: c.req.query('gpuType') ?? undefined,
|
||||
minGpuCount: Number(c.req.query('minGpuCount')) || undefined,
|
||||
maxPriceCents: Number(c.req.query('maxPriceCents')) || undefined,
|
||||
requiresHighSpeedInterconnect: c.req.query('fastFabric') === 'true',
|
||||
limit: Number(c.req.query('limit')) || 50,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
app.get('/api/commitments', async (c) => {
|
||||
const rows = await db
|
||||
.select({ commitment: capacityCommitments, accountName: accounts.name })
|
||||
.from(capacityCommitments)
|
||||
.leftJoin(accounts, eq(accounts.id, capacityCommitments.accountId))
|
||||
.orderBy(desc(capacityCommitments.startsAt));
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
app.get('/api/allocations', async (c) => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(allocations)
|
||||
.orderBy(desc(allocations.startsAt))
|
||||
.limit(300);
|
||||
return c.json(rows);
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------- dashboard
|
||||
|
||||
/**
|
||||
* The landing view. One round trip rather than six, because this renders on
|
||||
* a phone on a cellular connection as often as on a desk.
|
||||
*/
|
||||
app.get('/api/dashboard', async (c) => {
|
||||
const p = c.get('principal');
|
||||
const [margin, idle, openDemand, openSupply, recent] = await Promise.all([
|
||||
capacity.marginReport(),
|
||||
capacity.idleCapacity({ thresholdPct: 0.2 }),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(demandDeals)
|
||||
.where(sql`${demandDeals.stage} NOT IN ('closed_won','closed_lost')`),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(supplyDeals)
|
||||
.where(sql`${supplyDeals.stage} NOT IN ('live','churned','rejected')`),
|
||||
db
|
||||
.select()
|
||||
.from(activities)
|
||||
.orderBy(desc(activities.occurredAt))
|
||||
.limit(12),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
me: { name: p.name, teams: p.teams },
|
||||
margin: margin.totals,
|
||||
blocks: margin.blocks.length,
|
||||
idleAlerts: idle.slice(0, 5),
|
||||
openDemandDeals: openDemand[0]?.count ?? 0,
|
||||
openSupplyDeals: openSupply[0]?.count ?? 0,
|
||||
recentActivity: recent,
|
||||
});
|
||||
});
|
||||
|
||||
app.onError((error, c) => {
|
||||
if (error instanceof AuthError) {
|
||||
return c.json({ error: error.message, code: error.code }, error.status);
|
||||
}
|
||||
console.error('[pig] unhandled error', error);
|
||||
// Never leak internals to a client; the detail is in the server log.
|
||||
return c.json({ error: 'Internal error' }, 500);
|
||||
});
|
||||
|
||||
app.notFound((c) => c.json({ error: 'Not found' }, 404));
|
||||
|
||||
return app;
|
||||
}
|
||||
Reference in New Issue
Block a user