diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..aca3005 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,24 @@ +{ + "name": "@pig/api", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "main": "./src/index.ts", + "scripts": { + "dev": "tsx watch src/server.ts", + "start": "tsx src/server.ts", + "typecheck": "tsc --noEmit", + "test": "node --test --import tsx test/*.test.ts" + }, + "dependencies": { + "@pig/core": "*", + "@pig/db": "*", + "@pig/prime": "*", + "@hono/node-server": "^1.13.7", + "hono": "^4.6.14", + "drizzle-orm": "^0.38.3", + "jose": "^5.9.6", + "zod": "^3.24.1" + } +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..58ec9c7 --- /dev/null +++ b/apps/api/src/app.ts @@ -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(); + 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>(); + 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`count(*)::int` }) + .from(demandDeals) + .where(sql`${demandDeals.stage} NOT IN ('closed_won','closed_lost')`), + db + .select({ count: sql`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; +} diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts new file mode 100644 index 0000000..41a182e --- /dev/null +++ b/apps/api/src/lib/auth.ts @@ -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 { + 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 { + // 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 { + 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 = { 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'); +} diff --git a/apps/api/src/lib/config.ts b/apps/api/src/lib/config.ts new file mode 100644 index 0000000..daa7fc3 --- /dev/null +++ b/apps/api/src/lib/config.ts @@ -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 & { + 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.', + ); + } +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..af3a0e1 --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,66 @@ +/** + * The PIG server. + * + * Serves the API and, in production, the built front end from the same origin. + * Same-origin matters more than it might appear: the browser holds its auth + * session per origin, so splitting the app across two hostnames turns sign-in + * into a loop that looks like a broken deployment. + */ +import { serve } from '@hono/node-server'; +import { serveStatic } from '@hono/node-server/serve-static'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { createDatabase } from '@pig/db'; +import { createApp } from './app'; +import { loadConfig } from './lib/config'; +import { startPrimeSync } from './services/sync'; +import { CapacityService } from './services/capacity'; + +const config = loadConfig(); +const db = createDatabase({ url: config.DATABASE_URL }); +const app = createApp(config, db); + +// Serve the built SPA when it exists. Absent in development, where Vite serves +// it on its own port with hot reload. +const webDist = join(process.cwd(), 'apps/web/dist'); +if (existsSync(webDist)) { + app.use('/assets/*', serveStatic({ root: './apps/web/dist' })); + app.get('*', serveStatic({ path: './apps/web/dist/index.html' })); + console.log('[pig] serving front end from', webDist); +} + +const server = serve({ fetch: app.fetch, port: config.PIG_PORT }, (info) => { + console.log(`[pig] listening on http://localhost:${info.port}`); + console.log(`[pig] environment: ${config.NODE_ENV}`); +}); + +// Background work. Both are optional and the application is fully usable with +// neither running. +const stopSync = startPrimeSync(config, db); + +const capacity = new CapacityService(db); +const holdSweeper = setInterval( + () => { + void capacity + .sweepExpiredHolds() + .then((n) => n > 0 && console.log(`[pig] released ${n} expired capacity hold(s)`)) + .catch((error) => console.error('[pig] hold sweep failed', error)); + }, + 5 * 60 * 1000, +); + +/** + * Shut down cleanly so that in-flight requests finish and Postgres connections + * are returned rather than left for the server to reap. + */ +function shutdown(signal: string) { + console.log(`[pig] ${signal} received, shutting down`); + clearInterval(holdSweeper); + stopSync(); + server.close(() => process.exit(0)); + // Do not hang forever if a connection refuses to drain. + setTimeout(() => process.exit(1), 10_000).unref(); +} + +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/apps/api/src/services/capacity.ts b/apps/api/src/services/capacity.ts new file mode 100644 index 0000000..076738f --- /dev/null +++ b/apps/api/src/services/capacity.ts @@ -0,0 +1,418 @@ +/** + * Capacity: availability, matching and margin. + * + * This is PIG's business logic. Everything here answers one of three questions + * a compute GTM team asks constantly and cannot ask a generic CRM at all: + * + * "What can I actually sell?" → availability + * "What fits this customer?" → matching + * "What is it worth?" → margin + */ +import { and, eq, gte, inArray, isNull, lte, or, sql } from 'drizzle-orm'; +import type { Database } from '@pig/db'; +import { + allocations, + capacityCommitments, + CONSUMING_ALLOCATION_STATUSES, + RESERVING_ALLOCATION_STATUSES, + inventoryListings, +} from '@pig/db'; +import { computeMargin, breakEvenPricePerGpuHourCents } from '@pig/core'; +import type { InterconnectType, SecurityTier } from '@pig/core'; + +export interface CommitmentShape { + intervals: string[]; + quantities: number[]; +} + +/** + * GPUs held at an instant, according to a commitment's shape. + * + * A commitment is not a rectangle: it ramps across tranches and steps down at + * checkpoints. Where no shape is recorded the flat `gpuCount` applies for the + * whole window, which is the common simple case. + */ +export function quantityAt( + shape: CommitmentShape | null | undefined, + flatCount: number, + at: Date, +): number { + if (!shape || shape.intervals.length < 2) return flatCount; + const t = at.getTime(); + for (let i = 0; i < shape.quantities.length; i++) { + const start = Date.parse(shape.intervals[i]!); + const end = Date.parse(shape.intervals[i + 1]!); + if (Number.isFinite(start) && Number.isFinite(end) && t >= start && t < end) { + return shape.quantities[i] ?? 0; + } + } + // Outside every declared interval the commitment holds nothing. Falling back + // to the flat count here would invent capacity beyond the contract. + return 0; +} + +/** + * Total GPU-hours a commitment provides, integrating over its shape. + * + * Used to reconcile a recorded `totalGpuHours` against the shape actually + * entered — a mismatch usually means someone typed a headline number from a + * term sheet and then entered a ramp that does not add up to it. + */ +export function gpuHoursFromShape(shape: CommitmentShape): number { + let hours = 0; + for (let i = 0; i < shape.quantities.length; i++) { + const start = Date.parse(shape.intervals[i]!); + const end = Date.parse(shape.intervals[i + 1]!); + if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) continue; + hours += ((end - start) / 3_600_000) * (shape.quantities[i] ?? 0); + } + return hours; +} + +export interface AvailabilityRow { + commitmentId: string; + name: string; + gpuType: string; + gpuCount: number; + interconnectType: InterconnectType; + securityTier: SecurityTier; + startsAt: Date; + endsAt: Date; + totalGpuHours: number; + /** Hours consumed by allocations that count as sold. */ + soldGpuHours: number; + /** Hours held by unexpired holds. Reserved, but not yet sold. */ + heldGpuHours: number; + /** Hours neither sold nor held. What a seller may actually offer. */ + availableGpuHours: number; + costPerGpuHourCents: number; + utilisation: number; + /** Price at which the remaining hours break even on this block. */ + breakEvenPriceCents: number | null; +} + +export class CapacityService { + constructor(private readonly db: Database) {} + + /** + * What is genuinely sellable, per commitment. + * + * The key subtlety is that sold and held are counted separately. A live hold + * must remove capacity from availability — otherwise two sellers promise the + * same GPUs — but it is not revenue and must not inflate utilisation. A + * pipeline of optimistic holds should never be able to make the book look + * full. + * + * Expired holds are ignored here rather than requiring a sweep to have run, + * so availability is correct even if the cleanup job is behind. + */ + async availability(options: { at?: Date; gpuType?: string } = {}): Promise { + const now = options.at ?? new Date(); + + const commitments = await this.db + .select() + .from(capacityCommitments) + .where( + and( + isNull(capacityCommitments.terminatedAt), + gte(capacityCommitments.endsAt, now), + options.gpuType ? eq(capacityCommitments.gpuType, options.gpuType) : undefined, + ), + ); + + if (commitments.length === 0) return []; + + const ids = commitments.map((c) => c.id); + const allocRows = await this.db + .select() + .from(allocations) + .where( + and( + inArray(allocations.capacityCommitmentId, ids), + inArray(allocations.status, [...RESERVING_ALLOCATION_STATUSES]), + ), + ); + + return commitments.map((commitment) => { + const mine = allocRows.filter((a) => a.capacityCommitmentId === commitment.id); + + let soldGpuHours = 0; + let heldGpuHours = 0; + for (const a of mine) { + const hours = Number(a.gpuHours); + if ((CONSUMING_ALLOCATION_STATUSES as readonly string[]).includes(a.status)) { + soldGpuHours += hours; + } else if (!a.holdExpiresAt || a.holdExpiresAt > now) { + // A lapsed hold reserves nothing, whether or not it has been swept. + heldGpuHours += hours; + } + } + + const totalGpuHours = Number(commitment.totalGpuHours); + const margin = computeMargin( + { gpuHours: totalGpuHours, costPerGpuHourCents: commitment.costPerGpuHourCents }, + mine + .filter((a) => (CONSUMING_ALLOCATION_STATUSES as readonly string[]).includes(a.status)) + .map((a) => ({ + gpuHours: Number(a.gpuHours), + pricePerGpuHourCents: a.pricePerGpuHourCents, + })), + ); + + return { + commitmentId: commitment.id, + name: commitment.name, + gpuType: commitment.gpuType, + gpuCount: commitment.gpuCount, + interconnectType: commitment.interconnectType as InterconnectType, + securityTier: commitment.securityTier as SecurityTier, + startsAt: commitment.startsAt, + endsAt: commitment.endsAt, + totalGpuHours, + soldGpuHours, + heldGpuHours, + availableGpuHours: Math.max(0, totalGpuHours - soldGpuHours - heldGpuHours), + costPerGpuHourCents: commitment.costPerGpuHourCents, + utilisation: margin.utilisation, + breakEvenPriceCents: breakEvenPricePerGpuHourCents( + { gpuHours: totalGpuHours, costPerGpuHourCents: commitment.costPerGpuHourCents }, + mine + .filter((a) => + (CONSUMING_ALLOCATION_STATUSES as readonly string[]).includes(a.status), + ) + .map((a) => ({ + gpuHours: Number(a.gpuHours), + pricePerGpuHourCents: a.pricePerGpuHourCents, + })), + ), + }; + }); + } + + /** + * Match a customer requirement against capacity we hold. + * + * Interconnect is a hard filter rather than a preference. Selling + * Ethernet-only capacity to a distributed-training customer produces a + * cluster that cannot do the job, which is worse than losing the deal — so a + * requirement for high-speed fabric excludes `Ethernet` and `Unknown` alike. + * `Unknown` is excluded deliberately: unverified is not the same as adequate. + */ + async match(requirement: { + gpuType?: string; + gpuTypeAlternatives?: string[]; + gpuCount: number; + totalGpuHours?: number; + requiresHighSpeedInterconnect?: boolean; + minSecurityTier?: SecurityTier; + startsAt?: Date; + endsAt?: Date; + maxPricePerGpuHourCents?: number; + }): Promise< + (AvailabilityRow & { + /** 0–1. Higher is a better fit. */ + score: number; + /** Why this matched, in plain words, for the UI and for agents. */ + rationale: string[]; + })[] + > { + const acceptableTypes = [ + ...(requirement.gpuType ? [requirement.gpuType] : []), + ...(requirement.gpuTypeAlternatives ?? []), + ]; + + const rows = await this.availability({ at: requirement.startsAt ?? new Date() }); + + const matches = rows + .filter((row) => { + if (acceptableTypes.length > 0 && !acceptableTypes.includes(row.gpuType)) return false; + if (row.gpuCount < requirement.gpuCount) return false; + + if (requirement.requiresHighSpeedInterconnect) { + const fast: InterconnectType[] = ['Infiniband', 'RoCE', 'NVLink']; + if (!fast.includes(row.interconnectType)) return false; + } + if (requirement.minSecurityTier === 'secure_cloud' && row.securityTier !== 'secure_cloud') { + return false; + } + if (requirement.startsAt && row.startsAt > requirement.startsAt) return false; + if (requirement.endsAt && row.endsAt < requirement.endsAt) return false; + if (requirement.totalGpuHours && row.availableGpuHours < requirement.totalGpuHours) { + return false; + } + return true; + }) + .map((row) => { + const rationale: string[] = []; + let score = 0.5; + + // Prefer filling blocks that are sitting idle: the marginal hour on an + // under-utilised commitment is already paid for, so selling it is worth + // more than selling one from a block that is nearly full. + const idleBonus = 1 - row.utilisation; + score += idleBonus * 0.3; + if (row.utilisation < 0.5) { + rationale.push( + `Block is ${Math.round(row.utilisation * 100)}% utilised — selling here reduces idle spend.`, + ); + } + + if (requirement.gpuType && row.gpuType === requirement.gpuType) { + score += 0.1; + rationale.push(`Exact GPU match (${row.gpuType}).`); + } else if (acceptableTypes.includes(row.gpuType)) { + rationale.push(`Acceptable alternative (${row.gpuType}).`); + } + + if (requirement.requiresHighSpeedInterconnect) { + rationale.push(`${row.interconnectType} fabric meets the training requirement.`); + } + + // Margin headroom: can this be sold above break-even, within the + // customer's ceiling? + if (requirement.maxPricePerGpuHourCents && row.breakEvenPriceCents != null) { + if (row.breakEvenPriceCents <= requirement.maxPricePerGpuHourCents) { + score += 0.1; + rationale.push( + `Break-even is below the customer's ceiling — there is margin available.`, + ); + } else { + // Kept in the results but scored down, because a seller may still + // want to know the option exists and price it as a loss leader. + score -= 0.3; + rationale.push( + `⚠ Break-even exceeds the customer's stated ceiling. This would sell at a loss.`, + ); + } + } + + return { ...row, score: Math.max(0, Math.min(1, score)), rationale }; + }) + .sort((a, b) => b.score - a.score); + + return matches; + } + + /** + * Blocks with meaningful unsold capacity — the alert that pays for PIG. + * + * Committed hours are already paid for, so idle capacity is money leaving the + * business every hour it stays unsold. Filtered to blocks that are live or + * starting soon, since idle capacity in eighteen months is a forecasting + * matter rather than an alarm. + */ + async idleCapacity(options: { thresholdPct?: number; withinDays?: number } = {}) { + const threshold = options.thresholdPct ?? 0.25; + const horizon = new Date(Date.now() + (options.withinDays ?? 30) * 86_400_000); + + const rows = await this.availability(); + return rows + .filter((row) => row.startsAt <= horizon && 1 - row.utilisation >= threshold) + .map((row) => { + const idleHours = row.totalGpuHours - row.soldGpuHours; + return { + ...row, + idleGpuHours: idleHours, + // The number that makes the case: what the unsold hours cost us. + idleCostCents: Math.round(idleHours * row.costPerGpuHourCents), + }; + }) + .sort((a, b) => b.idleCostCents - a.idleCostCents); + } + + /** Book-level margin across every live commitment. */ + async marginReport(): Promise<{ + totals: ReturnType; + blocks: AvailabilityRow[]; + }> { + const blocks = await this.availability(); + const allocRows = blocks.length + ? await this.db + .select() + .from(allocations) + .where( + and( + inArray( + allocations.capacityCommitmentId, + blocks.map((b) => b.commitmentId), + ), + inArray(allocations.status, [...CONSUMING_ALLOCATION_STATUSES]), + ), + ) + : []; + + const books = blocks.map((block) => ({ + commitment: { + gpuHours: block.totalGpuHours, + costPerGpuHourCents: block.costPerGpuHourCents, + }, + allocations: allocRows + .filter((a) => a.capacityCommitmentId === block.commitmentId) + .map((a) => ({ + gpuHours: Number(a.gpuHours), + pricePerGpuHourCents: a.pricePerGpuHourCents, + })), + })); + + // Aggregate by summing cents, never by averaging per-block percentages — + // an average of ratios weights a tiny block equally with a huge one. + const { aggregateMargin } = await import('@pig/core'); + return { totals: aggregateMargin(books), blocks }; + } + + /** + * Release holds whose timer has lapsed. + * + * Availability already ignores expired holds, so this is bookkeeping rather + * than correctness — it keeps the stored state honest and makes the release + * visible in the UI. + */ + async sweepExpiredHolds(now = new Date()): Promise { + const result = await this.db + .update(allocations) + .set({ status: 'released', releasedAt: now, updatedAt: now }) + .where( + and( + eq(allocations.status, 'planned'), + sql`${allocations.holdExpiresAt} IS NOT NULL`, + lte(allocations.holdExpiresAt, now), + ), + ) + .returning({ id: allocations.id }); + return result.length; + } + + /** Live inventory from providers, for capacity we do not yet hold. */ + async searchInventory(query: { + gpuType?: string; + minGpuCount?: number; + maxPriceCents?: number; + requiresHighSpeedInterconnect?: boolean; + limit?: number; + }) { + const fast: InterconnectType[] = ['Infiniband', 'RoCE', 'NVLink']; + return this.db + .select() + .from(inventoryListings) + .where( + and( + query.gpuType ? eq(inventoryListings.gpuType, query.gpuType) : undefined, + query.minGpuCount ? gte(inventoryListings.gpuCount, query.minGpuCount) : undefined, + query.maxPriceCents + ? lte(inventoryListings.onDemandPriceCents, query.maxPriceCents) + : undefined, + query.requiresHighSpeedInterconnect + ? inArray(inventoryListings.interconnectType, fast) + : undefined, + // Unavailable stock is never a useful search result. + or( + eq(inventoryListings.stockStatus, 'Available'), + eq(inventoryListings.stockStatus, 'Low'), + eq(inventoryListings.stockStatus, 'Medium'), + eq(inventoryListings.stockStatus, 'High'), + ), + ), + ) + .limit(Math.min(query.limit ?? 50, 200)); + } +} diff --git a/apps/api/src/services/sync.ts b/apps/api/src/services/sync.ts new file mode 100644 index 0000000..4f66505 --- /dev/null +++ b/apps/api/src/services/sync.ts @@ -0,0 +1,130 @@ +/** + * Inventory sync from the Prime Intellect availability API. + * + * Runs on an interval, upserting listings on their natural key so repeated + * syncs converge rather than accumulating near-duplicates. + * + * Two decisions worth explaining. + * + * **Stale listings are marked, not deleted.** A listing that disappears + * upstream has usually sold out rather than ceased to exist, and deleting it + * would destroy the price history that makes the inventory useful for + * negotiation. Anything not seen in this pass is marked `Unavailable`, keeping + * the record and its history while removing it from search. + * + * **A failed sync is not fatal.** The upstream publishes no rate limits, so + * some failures are expected. The job logs and waits for the next tick rather + * than crashing the server that people are using. + */ +import { and, eq, inArray, lt, sql } from 'drizzle-orm'; +import type { Database } from '@pig/db'; +import { inventoryListings } from '@pig/db'; +import { PrimeClient, mapListing } from '@pig/prime'; +import type { Config } from '../lib/config'; + +export function startPrimeSync(config: Config, db: Database): () => void { + if (!config.PRIME_SYNC_ENABLED || !config.PRIME_API_KEY) { + if (config.PRIME_SYNC_ENABLED) { + console.warn('[pig] Prime sync enabled but no API key — not starting.'); + } + return () => {}; + } + + const client = new PrimeClient({ + apiKey: config.PRIME_API_KEY, + baseUrl: config.PRIME_API_BASE, + onRetry: ({ attempt, delayMs, reason }) => + console.warn(`[pig] prime retry ${attempt} in ${delayMs}ms (${reason})`), + }); + + let running = false; + + async function runOnce() { + // Overlapping runs would fight over the same rows for no benefit. + if (running) return; + running = true; + const startedAt = new Date(); + let upserted = 0; + + try { + for await (const page of client.iterateGpuAvailability()) { + const rows = page + .map(mapListing) + .filter((row): row is NonNullable => row !== null); + if (rows.length === 0) continue; + + for (const row of rows) { + await db + .insert(inventoryListings) + .values({ + ...row, + socket: row.socket as 'PCIe' | null, + source: 'prime_api', + observedAt: startedAt, + }) + .onConflictDoUpdate({ + target: [ + inventoryListings.externalCloudId, + inventoryListings.gpuType, + inventoryListings.socket, + inventoryListings.gpuCount, + inventoryListings.securityTier, + ], + set: { + stockStatus: row.stockStatus, + onDemandPriceCents: row.onDemandPriceCents, + communityPriceCents: row.communityPriceCents, + priceIsVariable: row.priceIsVariable, + interconnectGbps: row.interconnectGbps, + interconnectType: row.interconnectType, + provisioningMinutes: row.provisioningMinutes, + region: row.region, + country: row.country, + images: row.images, + raw: row.raw, + observedAt: startedAt, + updatedAt: new Date(), + }, + }); + upserted += 1; + } + } + + // Anything the provider stopped listing is out of stock, not gone. + const stale = await db + .update(inventoryListings) + .set({ stockStatus: 'Unavailable', updatedAt: new Date() }) + .where( + and( + eq(inventoryListings.source, 'prime_api'), + lt(inventoryListings.observedAt, startedAt), + sql`${inventoryListings.stockStatus} <> 'Unavailable'`, + ), + ) + .returning({ id: inventoryListings.id }); + + console.log( + `[pig] prime sync: ${upserted} listing(s) upserted, ${stale.length} marked unavailable`, + ); + } catch (error) { + // Logged, not thrown. A provider outage must not take down the CRM. + console.error('[pig] prime sync failed:', error instanceof Error ? error.message : error); + } finally { + running = false; + } + } + + // Delay the first run so startup is not competing with an outbound sync. + const initial = setTimeout(() => void runOnce(), 10_000); + const interval = setInterval( + () => void runOnce(), + config.PRIME_SYNC_INTERVAL_MINUTES * 60_000, + ); + + console.log(`[pig] prime sync every ${config.PRIME_SYNC_INTERVAL_MINUTES}m`); + + return () => { + clearTimeout(initial); + clearInterval(interval); + }; +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..58d1749 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true, "types": ["node"] }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/apps/mcp/package.json b/apps/mcp/package.json new file mode 100644 index 0000000..6e23786 --- /dev/null +++ b/apps/mcp/package.json @@ -0,0 +1,17 @@ +{ + "name": "@pig/mcp", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "bin": { "pig-mcp": "./src/stdio.ts" }, + "scripts": { + "dev": "tsx src/stdio.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@pig/core": "*", + "@modelcontextprotocol/sdk": "^1.30.0", + "zod": "^3.24.1" + } +} diff --git a/apps/mcp/src/server.ts b/apps/mcp/src/server.ts new file mode 100644 index 0000000..ec5c1eb --- /dev/null +++ b/apps/mcp/src/server.ts @@ -0,0 +1,573 @@ +/** + * The PIG MCP server. + * + * PIG is a first-class application for agents as well as for people, and this + * is that surface. Any MCP client connects — Claude Code, Codex, prime-agent, + * or a Buzz workspace agent through its ACP bridge — so a team member works + * from the terminal they already live in rather than being made to visit a web + * app to log a call. + * + * Design rules, learned from tool surfaces that went wrong: + * + * **Keep it small.** Nine tools, each doing one thing. A sprawling tool list + * degrades model performance more than it adds capability; anything genuinely + * niche belongs behind `pig_search` or the HTTP API. + * + * **Every tool is an authenticated API call.** This process holds a PIG API + * key and talks to the same HTTP API the browser uses. It has no database + * credentials and no privileged path, so an agent can never reach further than + * the person it acts for. + * + * **Return prose, not just JSON.** Results are formatted for a model to read + * and quote back to a human. A wall of raw JSON forces the model to re-derive + * meaning that the server already knows. + */ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +export interface PigMcpOptions { + /** Base URL of the PIG API, e.g. https://primeintellectgrowth.com */ + baseUrl: string; + /** A PIG API key (`pig_…`). Scope it to `read` unless writes are wanted. */ + apiKey: string; + fetchImpl?: typeof fetch; +} + +class PigApi { + constructor(private readonly options: PigMcpOptions) {} + + private get fetchImpl() { + return this.options.fetchImpl ?? fetch; + } + + async request(path: string, init: RequestInit = {}): Promise { + const response = await this.fetchImpl(`${this.options.baseUrl}${path}`, { + ...init, + headers: { + authorization: `Bearer ${this.options.apiKey}`, + 'content-type': 'application/json', + accept: 'application/json', + ...(init.headers ?? {}), + }, + }); + + if (!response.ok) { + const body = await response.text().catch(() => ''); + // Surface the real reason. An agent that is told "403 needs_profile" can + // tell its user to get an invite; one told "request failed" cannot. + throw new Error( + `PIG API ${response.status}: ${body.slice(0, 300) || response.statusText}`, + ); + } + return (await response.json()) as T; + } +} + +const money = (cents: number | null | undefined, currency = 'USD') => + cents == null + ? '—' + : new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cents / 100); + +const pct = (v: number | null | undefined) => + v == null ? '—' : `${(v * 100).toFixed(1)}%`; + +/** Wrap a handler so a thrown error becomes a readable tool error. */ +function ok(text: string) { + return { content: [{ type: 'text' as const, text }] }; +} + +export function createPigMcpServer(options: PigMcpOptions): McpServer { + const api = new PigApi(options); + const server = new McpServer({ name: 'pig', version: '0.1.0' }); + + // ------------------------------------------------------------------ whoami + + server.registerTool( + 'pig_whoami', + { + title: 'Who am I in PIG', + description: + 'Identify the PIG user this agent is acting for, and which teams they belong to ' + + '(supply, demand, research). Call this first when you need to know whose pipeline ' + + 'to look at or whether the user may see supply-side economics.', + inputSchema: {}, + }, + async () => { + const me = await api.request<{ + name: string; + email: string; + isPlatformAdmin: boolean; + teams: { team: string; role: string }[]; + }>('/api/me'); + const teams = me.teams.length + ? me.teams.map((t) => `${t.team} (${t.role})`).join(', ') + : 'no team membership'; + return ok( + `${me.name} <${me.email}>\nTeams: ${teams}${me.isPlatformAdmin ? '\nPlatform admin.' : ''}`, + ); + }, + ); + + // -------------------------------------------------------------- my pipeline + + server.registerTool( + 'pig_my_pipeline', + { + title: 'My pipeline', + description: + 'Summarise the current state of the business: margin across the capacity book, ' + + 'open deals on both sides, and any idle-capacity alerts. Good opening call when ' + + 'the user asks "where are we?" or "what needs attention?".', + inputSchema: {}, + }, + async () => { + const d = await api.request<{ + margin: { + revenueCents: number; + costCents: number; + grossMarginCents: number; + grossMarginPct: number | null; + utilisation: number; + idleGpuHours: number; + }; + blocks: number; + openDemandDeals: number; + openSupplyDeals: number; + idleAlerts: { name: string; gpuType: string; idleCostCents?: number }[]; + }>('/api/dashboard'); + + const lines = [ + `Capacity book: ${d.blocks} commitment(s), ${pct(d.margin.utilisation)} utilised.`, + `Revenue ${money(d.margin.revenueCents)} against cost ${money(d.margin.costCents)}.`, + `Gross margin ${money(d.margin.grossMarginCents)} (${pct(d.margin.grossMarginPct)}).`, + `Idle: ${Math.round(d.margin.idleGpuHours).toLocaleString()} GPU-hours bought and unsold.`, + '', + `Open deals — demand ${d.openDemandDeals}, supply ${d.openSupplyDeals}.`, + ]; + + if (d.idleAlerts.length) { + lines.push('', 'Idle capacity worth attention:'); + for (const a of d.idleAlerts) { + lines.push(` • ${a.name} (${a.gpuType}) — ${money(a.idleCostCents)} unsold`); + } + } + return ok(lines.join('\n')); + }, + ); + + // ------------------------------------------------------------ capacity match + + server.registerTool( + 'pig_capacity_match', + { + title: 'Match capacity to a requirement', + description: + 'THE most useful tool here. Given what a customer needs — GPU type, count, dates, ' + + 'whether they need high-speed interconnect for distributed training — find capacity ' + + 'we have already committed to and could sell them, ranked by fit. Prefers blocks ' + + 'sitting idle, because those hours are already paid for. Warns when a match would ' + + 'sell below break-even.', + inputSchema: { + gpuCount: z.number().int().positive().describe('Number of GPUs required'), + gpuType: z + .string() + .optional() + .describe('Preferred GPU, e.g. H100_80GB, H200, B200. Omit to consider all.'), + gpuTypeAlternatives: z + .array(z.string()) + .optional() + .describe('Acceptable substitutes, in preference order'), + totalGpuHours: z.number().positive().optional().describe('Total GPU-hours needed'), + requiresHighSpeedInterconnect: z + .boolean() + .optional() + .describe( + 'True for distributed training. Excludes Ethernet-only and unverified fabric.', + ), + startsAt: z.string().optional().describe('ISO-8601 start date'), + endsAt: z.string().optional().describe('ISO-8601 end date'), + maxPricePerGpuHourCents: z + .number() + .int() + .positive() + .optional() + .describe("Customer's price ceiling, in cents per GPU-hour"), + }, + }, + async (input) => { + const matches = await api.request< + { + name: string; + gpuType: string; + gpuCount: number; + interconnectType: string; + availableGpuHours: number; + utilisation: number; + costPerGpuHourCents: number; + breakEvenPriceCents: number | null; + score: number; + rationale: string[]; + }[] + >('/api/capacity/match', { method: 'POST', body: JSON.stringify(input) }); + + if (matches.length === 0) { + return ok( + 'No committed capacity matches that requirement.\n\n' + + 'Consider `pig_inventory_search` to find capacity available to buy from ' + + 'providers, which would need a new supply deal.', + ); + } + + const lines = [`${matches.length} match(es), best first:\n`]; + for (const m of matches.slice(0, 8)) { + lines.push( + `▸ ${m.name} — ${m.gpuCount}× ${m.gpuType}, ${m.interconnectType}`, + ` ${Math.round(m.availableGpuHours).toLocaleString()} GPU-hours free · ` + + `${pct(m.utilisation)} utilised · cost ${money(m.costPerGpuHourCents)}/GPU-hr`, + ` Break-even on remaining hours: ${money(m.breakEvenPriceCents)}/GPU-hr`, + ...m.rationale.map((r) => ` ${r}`), + '', + ); + } + return ok(lines.join('\n')); + }, + ); + + // ------------------------------------------------------------ margin report + + server.registerTool( + 'pig_margin_report', + { + title: 'Margin across the capacity book', + description: + 'Per-commitment and total margin: what each block of capacity cost, what it earned, ' + + 'and how much of it is sold. Cost is charged against the FULL commitment, not only ' + + 'the hours that sold, because unsold hours are already paid for.', + inputSchema: {}, + }, + async () => { + const report = await api.request<{ + totals: { + revenueCents: number; + costCents: number; + grossMarginCents: number; + grossMarginPct: number | null; + utilisation: number; + idleGpuHours: number; + marginPerAllocatedGpuHourCents: number | null; + }; + blocks: { + name: string; + gpuType: string; + utilisation: number; + soldGpuHours: number; + totalGpuHours: number; + costPerGpuHourCents: number; + }[]; + }>('/api/capacity/margin'); + + const t = report.totals; + const lines = [ + 'Book totals', + ` Revenue ${money(t.revenueCents)}`, + ` Cost ${money(t.costCents)}`, + ` Gross margin ${money(t.grossMarginCents)} (${pct(t.grossMarginPct)})`, + ` Per sold hour ${money(t.marginPerAllocatedGpuHourCents)}`, + ` Utilisation ${pct(t.utilisation)}`, + ` Idle ${Math.round(t.idleGpuHours).toLocaleString()} GPU-hours`, + '', + 'By commitment', + ]; + for (const b of report.blocks) { + lines.push( + ` ${b.name} (${b.gpuType}) — ${pct(b.utilisation)} sold ` + + `(${Math.round(b.soldGpuHours).toLocaleString()}/${Math.round(b.totalGpuHours).toLocaleString()} hrs) ` + + `at ${money(b.costPerGpuHourCents)}/hr cost`, + ); + } + return ok(lines.join('\n')); + }, + ); + + // ------------------------------------------------------------ idle capacity + + server.registerTool( + 'pig_idle_capacity', + { + title: 'Idle capacity', + description: + 'Committed capacity that is not sold, ranked by what it is costing. These hours are ' + + 'already paid for, so this is money leaving the business every hour it stays unsold. ' + + 'Use it to decide what to push.', + inputSchema: { + thresholdPct: z + .number() + .min(0) + .max(1) + .optional() + .describe('Minimum idle fraction to report. Default 0.25.'), + withinDays: z + .number() + .int() + .positive() + .optional() + .describe('Only blocks live or starting within this many days. Default 30.'), + }, + }, + async (input) => { + const params = new URLSearchParams(); + if (input.thresholdPct != null) params.set('threshold', String(input.thresholdPct)); + if (input.withinDays != null) params.set('withinDays', String(input.withinDays)); + + const rows = await api.request< + { + name: string; + gpuType: string; + gpuCount: number; + idleGpuHours: number; + idleCostCents: number; + utilisation: number; + breakEvenPriceCents: number | null; + endsAt: string; + }[] + >(`/api/capacity/idle?${params}`); + + if (rows.length === 0) return ok('No idle capacity above the threshold. The book is tight.'); + + const total = rows.reduce((s, r) => s + r.idleCostCents, 0); + const lines = [`${rows.length} block(s) with idle capacity — ${money(total)} at stake:\n`]; + for (const r of rows) { + lines.push( + `▸ ${r.name} — ${r.gpuCount}× ${r.gpuType}`, + ` ${Math.round(r.idleGpuHours).toLocaleString()} GPU-hours unsold, costing ${money(r.idleCostCents)}`, + ` ${pct(r.utilisation)} utilised · sell above ${money(r.breakEvenPriceCents)}/GPU-hr to break even`, + ` Block ends ${new Date(r.endsAt).toISOString().slice(0, 10)}`, + '', + ); + } + return ok(lines.join('\n')); + }, + ); + + // -------------------------------------------------------- inventory search + + server.registerTool( + 'pig_inventory_search', + { + title: 'Search provider inventory', + description: + 'Search GPU capacity available to BUY from providers — synced live from the compute ' + + 'marketplace. This is capacity we do not yet hold; use it when demand exists that ' + + 'committed capacity cannot cover, then open a supply deal.', + inputSchema: { + gpuType: z.string().optional().describe('e.g. H100_80GB, H200, B200'), + minGpuCount: z.number().int().positive().optional(), + maxPriceCents: z + .number() + .int() + .positive() + .optional() + .describe('Ceiling on on-demand price, in cents per GPU-hour'), + requiresHighSpeedInterconnect: z.boolean().optional(), + limit: z.number().int().positive().max(100).optional(), + }, + }, + async (input) => { + const params = new URLSearchParams(); + if (input.gpuType) params.set('gpuType', input.gpuType); + if (input.minGpuCount) params.set('minGpuCount', String(input.minGpuCount)); + if (input.maxPriceCents) params.set('maxPriceCents', String(input.maxPriceCents)); + if (input.requiresHighSpeedInterconnect) params.set('fastFabric', 'true'); + if (input.limit) params.set('limit', String(input.limit)); + + const rows = await api.request< + { + providerSlug: string | null; + gpuType: string; + gpuCount: number; + socket: string | null; + interconnectType: string; + region: string | null; + country: string | null; + securityTier: string; + stockStatus: string; + onDemandPriceCents: number | null; + provisioningMinutes: number | null; + observedAt: string; + }[] + >(`/api/inventory?${params}`); + + if (rows.length === 0) { + return ok( + 'No matching inventory. Note that sync may be disabled, or the filters may be ' + + 'narrower than current market supply.', + ); + } + + const lines = [`${rows.length} listing(s):\n`]; + for (const r of rows.slice(0, 25)) { + lines.push( + `▸ ${r.gpuCount}× ${r.gpuType}${r.socket ? ` ${r.socket}` : ''} — ` + + `${money(r.onDemandPriceCents)}/GPU-hr`, + ` ${r.providerSlug ?? 'unknown provider'} · ${r.region ?? r.country ?? 'region unknown'} · ` + + `${r.interconnectType} · ${r.securityTier} · ${r.stockStatus}` + + (r.provisioningMinutes ? ` · ~${r.provisioningMinutes}m to provision` : ''), + ); + } + lines.push( + '', + `Prices last observed ${new Date(rows[0]!.observedAt).toISOString().slice(0, 16)}Z.`, + ); + return ok(lines.join('\n')); + }, + ); + + // ------------------------------------------------------------------ search + + server.registerTool( + 'pig_search', + { + title: 'Search accounts', + description: + 'Find accounts by name. Returns which side of the market they sit on (supply, demand, ' + + 'or both) so you know whether they are a provider, a customer, or each in turn.', + inputSchema: { + query: z.string().min(1).describe('Name or partial name'), + side: z.enum(['supply', 'demand', 'all']).optional(), + }, + }, + async (input) => { + const params = new URLSearchParams({ q: input.query }); + if (input.side) params.set('side', input.side); + + const rows = await api.request< + { + id: string; + name: string; + domain: string | null; + side: string; + supplierType: string | null; + customerSegment: string | null; + country: string | null; + confidence: string; + }[] + >(`/api/accounts?${params}`); + + if (rows.length === 0) return ok(`No accounts matching "${input.query}".`); + + return ok( + rows + .slice(0, 25) + .map( + (r) => + `▸ ${r.name}${r.domain ? ` (${r.domain})` : ''} — ${r.side}` + + `${r.supplierType ? `, ${r.supplierType}` : ''}` + + `${r.customerSegment ? `, ${r.customerSegment}` : ''}` + + `${r.confidence !== 'confirmed' ? ` [${r.confidence}]` : ''}\n id: ${r.id}`, + ) + .join('\n'), + ); + }, + ); + + // ------------------------------------------------------------- get account + + server.registerTool( + 'pig_get_account', + { + title: 'Get an account', + description: + 'Full detail for one account: contacts, deals on both sides, contracts, and recent ' + + 'activity. Use `pig_search` first to find the id.', + inputSchema: { accountId: z.string().uuid() }, + }, + async (input) => { + const d = await api.request<{ + account: Record & { name: string; side: string }; + contacts: { fullName: string; title: string | null; confidence: string }[]; + demandDeals: { name: string; stage: string; acvCents: number | null }[]; + supplyDeals: { name: string; stage: string }[]; + contracts: { title: string; type: string; status: string }[]; + activities: { type: string; subject: string | null; occurredAt: string }[]; + }>(`/api/accounts/${input.accountId}`); + + const lines = [`${d.account.name} — ${d.account.side} side`, '']; + + if (d.contacts.length) { + lines.push('Contacts'); + for (const c of d.contacts) { + lines.push( + ` • ${c.fullName}${c.title ? ` — ${c.title}` : ''}` + + // Surface weak provenance rather than presenting every record as + // equally solid; some of these are single-source claims. + (c.confidence !== 'confirmed' ? ` [${c.confidence}]` : ''), + ); + } + lines.push(''); + } + + if (d.demandDeals.length) { + lines.push('Demand deals'); + for (const x of d.demandDeals) { + lines.push(` • ${x.name} — ${x.stage}${x.acvCents ? ` · ${money(x.acvCents)} ACV` : ''}`); + } + lines.push(''); + } + + if (d.supplyDeals.length) { + lines.push('Supply deals'); + for (const x of d.supplyDeals) lines.push(` • ${x.name} — ${x.stage}`); + lines.push(''); + } + + if (d.contracts.length) { + lines.push('Contracts'); + for (const x of d.contracts) { + lines.push(` • ${x.title} — ${x.type.toUpperCase()}, ${x.status}`); + } + lines.push(''); + } + + if (d.activities.length) { + lines.push('Recent activity'); + for (const a of d.activities.slice(0, 8)) { + lines.push( + ` • ${new Date(a.occurredAt).toISOString().slice(0, 10)} ${a.type}` + + `${a.subject ? ` — ${a.subject}` : ''}`, + ); + } + } + + return ok(lines.join('\n')); + }, + ); + + // ------------------------------------------------------------- log activity + + server.registerTool( + 'pig_log_activity', + { + title: 'Log an activity', + description: + 'Record a call, meeting, email or note against an account. Use this after a ' + + 'conversation so the CRM reflects what actually happened rather than what someone ' + + 'remembers to type in later.', + inputSchema: { + accountId: z.string().uuid(), + type: z.enum(['note', 'email', 'call', 'meeting', 'slack', 'buzz']), + subject: z.string().min(1).max(200), + body: z.string().max(8000).optional(), + occurredAt: z.string().optional().describe('ISO-8601. Defaults to now.'), + }, + }, + async (input) => { + await api.request('/api/activities', { + method: 'POST', + body: JSON.stringify(input), + }); + return ok(`Logged: ${input.type} — ${input.subject}`); + }, + ); + + return server; +} diff --git a/apps/mcp/src/stdio.ts b/apps/mcp/src/stdio.ts new file mode 100755 index 0000000..b46422a --- /dev/null +++ b/apps/mcp/src/stdio.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/** + * stdio entry point — how a local agent connects. + * + * claude mcp add pig -- npx -y @pig/mcp + * + * Configuration comes from the environment because stdio servers have no other + * channel: PIG_URL and PIG_API_KEY. The key is minted in PIG under Settings → + * API keys, and should carry the narrowest scope that does the job. + */ +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { createPigMcpServer } from './server'; + +const baseUrl = process.env.PIG_URL ?? 'http://localhost:8920'; +const apiKey = process.env.PIG_API_KEY; + +if (!apiKey) { + // stdout is the protocol channel — diagnostics must go to stderr or they + // corrupt the stream and the client reports an unhelpful parse error. + process.stderr.write( + 'PIG_API_KEY is not set.\n' + + 'Create a key in PIG under Settings → API keys, then:\n' + + ' export PIG_API_KEY=pig_...\n' + + ' export PIG_URL=https://your-pig-host\n', + ); + process.exit(1); +} + +const server = createPigMcpServer({ baseUrl, apiKey }); +await server.connect(new StdioServerTransport()); diff --git a/apps/mcp/tsconfig.json b/apps/mcp/tsconfig.json new file mode 100644 index 0000000..0ea5fe8 --- /dev/null +++ b/apps/mcp/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"], + // The MCP SDK's 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 (Node ESM requires an + // exact file), so map the types explicitly rather than changing the import. + "paths": { + "@modelcontextprotocol/sdk/*.js": ["../../node_modules/@modelcontextprotocol/sdk/dist/esm/*.d.ts"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/package-lock.json b/package-lock.json index f661148..71cf5e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,34 @@ "node": ">=22" } }, + "apps/api": { + "name": "@pig/api", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@hono/node-server": "^1.13.7", + "@pig/core": "*", + "@pig/db": "*", + "@pig/prime": "*", + "drizzle-orm": "^0.38.3", + "hono": "^4.6.14", + "jose": "^5.9.6", + "zod": "^3.24.1" + } + }, + "apps/mcp": { + "name": "@pig/mcp", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "@pig/core": "*", + "zod": "^3.24.1" + }, + "bin": { + "pig-mcp": "src/stdio.ts" + } + }, "node_modules/@drizzle-team/brocli": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz", @@ -906,6 +934,67 @@ "node": ">=18" } }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@modelcontextprotocol/sdk/node_modules/jose": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.8.tgz", + "integrity": "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/@petamoriken/float16": { "version": "3.9.3", "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", @@ -913,6 +1002,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@pig/api": { + "resolved": "apps/api", + "link": true + }, "node_modules/@pig/core": { "resolved": "packages/core", "link": true @@ -921,6 +1014,14 @@ "resolved": "packages/db", "link": true }, + "node_modules/@pig/mcp": { + "resolved": "apps/mcp", + "link": true + }, + "node_modules/@pig/prime": { + "resolved": "packages/prime", + "link": true + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", @@ -931,6 +1032,89 @@ "undici-types": "~6.21.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -938,11 +1122,140 @@ "dev": true, "license": "MIT" }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -956,6 +1269,15 @@ } } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/drizzle-kit": { "version": "0.30.6", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.30.6.tgz", @@ -1528,6 +1850,35 @@ } } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -1541,6 +1892,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", @@ -1596,6 +1977,165 @@ "esbuild": ">=0.12 <1" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1611,6 +2151,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gel": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/gel/-/gel-2.2.0.tgz", @@ -1632,6 +2181,43 @@ "node": ">= 18.0.0" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-tsconfig": { "version": "4.14.2", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.2.tgz", @@ -1645,6 +2231,117 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", + "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", @@ -1655,13 +2352,180 @@ "node": ">=18" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postgres": { "version": "3.4.9", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz", @@ -1675,6 +2539,72 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -1685,6 +2615,28 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -1698,6 +2650,78 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/shell-quote": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.10.0.tgz", @@ -1711,6 +2735,78 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -1732,6 +2828,24 @@ "source-map": "^0.6.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tsx": { "version": "4.23.12", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", @@ -1751,6 +2865,37 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -1772,6 +2917,24 @@ "dev": true, "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/which": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", @@ -1788,6 +2951,12 @@ "node": "^16.13.0 || >=18.0.0" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/zod": { "version": "3.25.76", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", @@ -1797,6 +2966,15 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + }, "packages/core": { "name": "@pig/core", "version": "0.1.0", @@ -1817,6 +2995,14 @@ "devDependencies": { "drizzle-kit": "^0.30.1" } + }, + "packages/prime": { + "name": "@pig/prime", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@pig/core": "*" + } } } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 707414d..99e9895 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,2 +1,3 @@ export * from './ontology'; export * from './margin'; +export * from './theme'; diff --git a/packages/core/src/theme.ts b/packages/core/src/theme.ts new file mode 100644 index 0000000..9cbe7c3 --- /dev/null +++ b/packages/core/src/theme.ts @@ -0,0 +1,124 @@ +/** + * The PIG palette. + * + * Users pick an accent and the whole interface re-tints from it. The accent is + * stored as a KEY rather than a hex string, for two reasons: the palette can be + * retuned centrally without migrating anyone's saved preference, and nobody can + * choose a colour that is illegible against the surfaces. + * + * Every accent below is specified as HSL triples for light and dark surfaces + * independently. A colour that reads well on white is usually too dark on + * near-black, so the dark variants are lifted in lightness and slightly + * desaturated — the same hue, tuned twice, rather than one value used in both + * places and looking wrong in one of them. + * + * Contrast: every `fg` is chosen to clear WCAG AA (4.5:1) against its own + * surface, and `on` is the text colour that clears AA against the accent itself + * when used as a solid fill. + */ + +export interface AccentDefinition { + key: string; + label: string; + /** Light-mode: accent as HSL channels, `H S% L%`, for CSS `hsl(var(--x))`. */ + light: { accent: string; fg: string; on: string; subtle: string }; + /** Dark-mode equivalents. */ + dark: { accent: string; fg: string; on: string; subtle: string }; +} + +/** + * `pig` is the default and the brand: near-black in light mode, near-white in + * dark. The mascot is a black-and-white pig, so the product's own accent is + * monochrome and every other choice is the user's personality rather than ours. + */ +export const ACCENTS: AccentDefinition[] = [ + { + key: 'pig', + label: 'Pig', + light: { + accent: '240 6% 10%', + fg: '240 6% 10%', + on: '0 0% 100%', + subtle: '240 5% 96%', + }, + dark: { + accent: '0 0% 98%', + fg: '0 0% 98%', + on: '240 6% 10%', + subtle: '240 4% 16%', + }, + }, + { + key: 'rose', + label: 'Rose', + light: { accent: '346 77% 50%', fg: '346 77% 42%', on: '0 0% 100%', subtle: '346 77% 97%' }, + dark: { accent: '346 84% 62%', fg: '346 90% 72%', on: '346 90% 12%', subtle: '346 40% 18%' }, + }, + { + key: 'amber', + label: 'Amber', + light: { accent: '32 95% 44%', fg: '28 80% 36%', on: '0 0% 100%', subtle: '38 92% 95%' }, + dark: { accent: '38 92% 58%', fg: '43 96% 68%', on: '26 83% 12%', subtle: '30 40% 18%' }, + }, + { + key: 'emerald', + label: 'Emerald', + light: { accent: '160 84% 32%', fg: '161 88% 26%', on: '0 0% 100%', subtle: '152 76% 96%' }, + dark: { accent: '158 64% 48%', fg: '156 72% 62%', on: '160 90% 10%', subtle: '158 35% 16%' }, + }, + { + key: 'sky', + label: 'Sky', + light: { accent: '201 90% 40%', fg: '202 90% 33%', on: '0 0% 100%', subtle: '204 94% 96%' }, + dark: { accent: '199 89% 58%', fg: '198 93% 68%', on: '202 90% 10%', subtle: '200 40% 17%' }, + }, + { + key: 'violet', + label: 'Violet', + light: { accent: '262 83% 55%', fg: '263 70% 46%', on: '0 0% 100%', subtle: '270 100% 97%' }, + dark: { accent: '258 90% 70%', fg: '255 92% 78%', on: '264 80% 12%', subtle: '260 35% 20%' }, + }, + { + key: 'slate', + label: 'Slate', + light: { accent: '215 25% 35%', fg: '215 25% 28%', on: '0 0% 100%', subtle: '210 40% 96%' }, + dark: { accent: '213 27% 74%', fg: '214 32% 82%', on: '215 28% 12%', subtle: '215 20% 18%' }, + }, +]; + +export const ACCENT_KEYS = ACCENTS.map((a) => a.key); +export const DEFAULT_ACCENT = 'pig'; + +export const THEME_MODES = ['light', 'dark', 'system'] as const; +export type ThemeMode = (typeof THEME_MODES)[number]; +export const DEFAULT_THEME_MODE: ThemeMode = 'system'; + +export function getAccent(key: string | null | undefined): AccentDefinition { + return ACCENTS.find((a) => a.key === key) ?? ACCENTS[0]!; +} + +export function isValidAccent(key: string): boolean { + return ACCENT_KEYS.includes(key); +} + +export function isValidThemeMode(mode: string): mode is ThemeMode { + return (THEME_MODES as readonly string[]).includes(mode); +} + +/** + * Semantic colours for pipeline stages and health states. + * + * Deliberately independent of the user's accent: if "at risk" re-tinted to + * whatever someone picked, a violet enthusiast would see warnings in violet and + * the signal would be gone. Status colour must mean the same thing for + * everyone. + */ +export const STATUS_COLORS = { + positive: { light: '160 84% 32%', dark: '158 64% 52%' }, + warning: { light: '32 95% 44%', dark: '38 92% 60%' }, + danger: { light: '0 72% 45%', dark: '0 84% 65%' }, + info: { light: '201 90% 40%', dark: '199 89% 60%' }, + neutral: { light: '240 4% 46%', dark: '240 5% 65%' }, +} as const; + +export type StatusColor = keyof typeof STATUS_COLORS; diff --git a/packages/db/src/schema/identity.ts b/packages/db/src/schema/identity.ts index 2fe60eb..dc3a836 100644 --- a/packages/db/src/schema/identity.ts +++ b/packages/db/src/schema/identity.ts @@ -48,6 +48,20 @@ export const users = pgTable( */ isPlatformAdmin: boolean('is_platform_admin').notNull().default(false), + /** + * Appearance preferences, persisted server-side rather than in + * localStorage so a person's chosen look follows them between their + * laptop and their phone. `system` defers to the OS. + */ + themeMode: text('theme_mode').notNull().default('system'), + /** + * Accent colour key from the shared palette in @pig/core. The whole + * interface re-tints from this one value. Stored as a key rather than a + * hex string so the palette can be retuned centrally — and so a user + * cannot pick something illegible against the surface colours. + */ + accentColor: text('accent_color').notNull().default('pig'), + /** Set when the person stops using PIG. Rows are retained for audit. */ deactivatedAt: timestamp('deactivated_at', { withTimezone: true }), diff --git a/packages/prime/package.json b/packages/prime/package.json new file mode 100644 index 0000000..500235e --- /dev/null +++ b/packages/prime/package.json @@ -0,0 +1,12 @@ +{ + "name": "@pig/prime", + "version": "0.1.0", + "private": true, + "license": "Apache-2.0", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { ".": "./src/index.ts" }, + "scripts": { "typecheck": "tsc --noEmit" }, + "dependencies": { "@pig/core": "*" } +} diff --git a/packages/prime/src/client.ts b/packages/prime/src/client.ts new file mode 100644 index 0000000..2206092 --- /dev/null +++ b/packages/prime/src/client.ts @@ -0,0 +1,351 @@ +/** + * A typed client for the Prime Intellect compute API. + * + * Written by hand because no first-party TypeScript SDK exists — the official + * SDK and CLI are Python. The surface here is deliberately narrow: PIG reads + * GPU availability to populate its inventory and does nothing else. It cannot + * provision, terminate, or spend money, and the API key it holds should be + * scoped so that it could not even if the code tried. + * + * Two operational realities shape this file. + * + * **Rate limits are undocumented.** There is no published quota, so the client + * cannot pace itself against a known budget. It instead backs off empirically: + * exponential with jitter on 429 and 5xx, honouring `Retry-After` when the + * server sends one, and giving up rather than hammering. + * + * **The upstream shape may drift.** Responses are parsed defensively; unknown + * fields are preserved verbatim in `raw` rather than dropped, so a field that + * appears upstream tomorrow is already captured today. + */ + +export interface PrimeClientOptions { + apiKey: string; + baseUrl?: string; + /** Total attempts per request, including the first. */ + maxAttempts?: number; + /** Ceiling on backoff between attempts. */ + maxBackoffMs?: number; + /** Per-request timeout. */ + timeoutMs?: number; + fetchImpl?: typeof fetch; + onRetry?: (info: { attempt: number; delayMs: number; reason: string }) => void; +} + +export class PrimeApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly body?: string, + ) { + super(message); + this.name = 'PrimeApiError'; + } + + /** Retrying a 4xx that is not 429 will fail identically every time. */ + get isRetryable(): boolean { + return this.status === 429 || this.status >= 500; + } +} + +/** A GPU availability listing, as returned by the availability endpoints. */ +export interface PrimeGpuListing { + cloudId?: string; + gpuType?: string; + socket?: string; + provider?: string; + region?: string; + dataCenter?: string; + country?: string; + gpuCount?: number; + gpuMemory?: number; + vcpu?: { defaultCount?: number } | number; + memory?: { defaultCount?: number } | number; + disk?: { + minCount?: number; + defaultCount?: number; + maxCount?: number; + pricePerUnit?: number; + }; + internetSpeed?: number; + interconnect?: number; + interconnectType?: string; + provisioningTime?: number; + stockStatus?: string; + security?: string; + prices?: { + onDemand?: number | null; + communityPrice?: number | null; + isVariable?: boolean; + currency?: string; + }; + images?: string[]; + isSpot?: boolean; + prepaidTime?: number; + /** Everything the upstream sent, including fields not modelled above. */ + raw: Record; +} + +export interface PrimeAvailabilityQuery { + regions?: string[]; + gpuCount?: number; + gpuType?: string; + socket?: string; + security?: 'secure_cloud' | 'community_cloud'; + dataCenterId?: string; + cloudId?: string; + page?: number; + /** Upstream caps this at 100. Values above are clamped rather than rejected. */ + pageSize?: number; +} + +const DEFAULT_BASE_URL = 'https://api.primeintellect.ai'; + +export class PrimeClient { + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly maxAttempts: number; + private readonly maxBackoffMs: number; + private readonly timeoutMs: number; + private readonly fetchImpl: typeof fetch; + private readonly onRetry: PrimeClientOptions['onRetry']; + + constructor(options: PrimeClientOptions) { + if (!options.apiKey) throw new Error('PrimeClient requires an apiKey.'); + this.apiKey = options.apiKey; + this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); + this.maxAttempts = options.maxAttempts ?? 5; + this.maxBackoffMs = options.maxBackoffMs ?? 30_000; + this.timeoutMs = options.timeoutMs ?? 30_000; + this.fetchImpl = options.fetchImpl ?? fetch; + this.onRetry = options.onRetry; + } + + /** + * One page of GPU availability. + * + * Returns the listings plus `totalCount` so a caller can page without + * guessing. Note that upstream paginates from page 1, not 0. + */ + async listGpuAvailability( + query: PrimeAvailabilityQuery = {}, + ): Promise<{ items: PrimeGpuListing[]; totalCount: number }> { + const params = new URLSearchParams(); + for (const region of query.regions ?? []) params.append('regions', region); + if (query.gpuCount != null) params.set('gpu_count', String(query.gpuCount)); + if (query.gpuType) params.set('gpu_type', query.gpuType); + if (query.socket) params.set('socket', query.socket); + if (query.security) params.set('security', query.security); + if (query.dataCenterId) params.set('data_center_id', query.dataCenterId); + if (query.cloudId) params.set('cloud_id', query.cloudId); + params.set('page', String(query.page ?? 1)); + // Upstream rejects page_size above 100; clamp rather than surface a 400. + params.set('page_size', String(Math.min(query.pageSize ?? 100, 100))); + + const body = await this.request(`/api/v1/availability/gpus?${params}`); + return normaliseListingPage(body); + } + + /** + * Every page of GPU availability, as an async iterable. + * + * Yields page by page rather than accumulating, so a large inventory does not + * have to fit in memory at once and the caller can begin upserting + * immediately. Stops when a page comes back empty, which also protects + * against a `totalCount` that disagrees with reality. + */ + async *iterateGpuAvailability( + query: Omit = {}, + ): AsyncGenerator { + let page = 1; + let seen = 0; + for (;;) { + const { items, totalCount } = await this.listGpuAvailability({ ...query, page }); + if (items.length === 0) return; + yield items; + seen += items.length; + if (totalCount > 0 && seen >= totalCount) return; + page += 1; + // A defensive ceiling. Without it a misbehaving upstream that always + // returns a full page would loop until the process is killed. + if (page > 1000) return; + } + } + + /** Multi-node cluster availability — the shape that can actually train. */ + async listMultiNodeAvailability( + query: PrimeAvailabilityQuery = {}, + ): Promise<{ items: PrimeGpuListing[]; totalCount: number }> { + const params = new URLSearchParams(); + if (query.gpuType) params.set('gpu_type', query.gpuType); + if (query.gpuCount != null) params.set('gpu_count', String(query.gpuCount)); + params.set('page', String(query.page ?? 1)); + params.set('page_size', String(Math.min(query.pageSize ?? 100, 100))); + + const body = await this.request(`/api/v1/availability/multi-node?${params}`); + return normaliseListingPage(body); + } + + /** + * Cheap credential check. Used by the settings screen so an operator finds + * out their key is wrong at configuration time rather than at 3am when the + * sync silently stops. + */ + async verifyCredentials(): Promise<{ ok: boolean; detail?: string }> { + try { + await this.listGpuAvailability({ pageSize: 1 }); + return { ok: true }; + } catch (error) { + if (error instanceof PrimeApiError) { + return { + ok: false, + detail: + error.status === 401 || error.status === 403 + ? 'Key rejected. Check it has the Availability → Read scope and has not expired.' + : `Upstream returned ${error.status}.`, + }; + } + return { ok: false, detail: error instanceof Error ? error.message : 'Unknown error' }; + } + } + + private async request(path: string): Promise { + let lastError: unknown; + + for (let attempt = 1; attempt <= this.maxAttempts; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const response = await this.fetchImpl(`${this.baseUrl}${path}`, { + headers: { + authorization: `Bearer ${this.apiKey}`, + accept: 'application/json', + 'user-agent': 'pig-crm/0.1 (+https://primeintellectgrowth.com)', + }, + signal: controller.signal, + }); + + if (response.ok) return (await response.json()) as T; + + const text = await response.text().catch(() => ''); + const error = new PrimeApiError( + `Prime Intellect API returned ${response.status}`, + response.status, + text.slice(0, 500), + ); + if (!error.isRetryable || attempt === this.maxAttempts) throw error; + + // Honour Retry-After when offered; it is better information than any + // backoff curve we could invent. + const retryAfter = parseRetryAfter(response.headers.get('retry-after')); + const delay = retryAfter ?? this.backoffMs(attempt); + this.onRetry?.({ attempt, delayMs: delay, reason: `HTTP ${response.status}` }); + await sleep(delay); + lastError = error; + } catch (error) { + if (error instanceof PrimeApiError) { + if (!error.isRetryable || attempt === this.maxAttempts) throw error; + lastError = error; + } else { + // Network failure or timeout. Both are worth retrying. + if (attempt === this.maxAttempts) throw error; + const delay = this.backoffMs(attempt); + this.onRetry?.({ + attempt, + delayMs: delay, + reason: error instanceof Error ? error.message : 'network error', + }); + await sleep(delay); + lastError = error; + } + } finally { + clearTimeout(timer); + } + } + + throw lastError ?? new Error('Prime Intellect request failed.'); + } + + /** + * Exponential backoff with full jitter. + * + * Jitter matters more than the curve: without it, several workers that hit a + * limit together retry together, and the thundering herd reproduces the + * problem that caused the limit. + */ + private backoffMs(attempt: number): number { + const ceiling = Math.min(this.maxBackoffMs, 1000 * 2 ** (attempt - 1)); + return Math.round(Math.random() * ceiling); + } +} + +function parseRetryAfter(header: string | null): number | null { + if (!header) return null; + const seconds = Number(header); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); + const date = Date.parse(header); + if (Number.isFinite(date)) return Math.max(0, date - Date.now()); + return null; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Normalise a page of listings. + * + * The upstream has returned both `{items, totalCount}` and a bare array across + * its endpoint generations, so both are accepted rather than assuming one and + * breaking on the other. + */ +function normaliseListingPage(body: unknown): { + items: PrimeGpuListing[]; + totalCount: number; +} { + const rawItems: unknown[] = Array.isArray(body) + ? body + : Array.isArray((body as { items?: unknown[] })?.items) + ? ((body as { items: unknown[] }).items ?? []) + : []; + + const totalCount = + typeof (body as { totalCount?: number })?.totalCount === 'number' + ? (body as { totalCount: number }).totalCount + : rawItems.length; + + return { items: rawItems.map(toListing), totalCount }; +} + +function toListing(raw: unknown): PrimeGpuListing { + const r = (raw ?? {}) as Record; + return { + cloudId: str(r.cloudId), + gpuType: str(r.gpuType), + socket: str(r.socket), + provider: str(r.provider), + region: str(r.region), + dataCenter: str(r.dataCenter), + country: str(r.country), + gpuCount: num(r.gpuCount), + gpuMemory: num(r.gpuMemory), + vcpu: r.vcpu as PrimeGpuListing['vcpu'], + memory: r.memory as PrimeGpuListing['memory'], + disk: r.disk as PrimeGpuListing['disk'], + internetSpeed: num(r.internetSpeed), + interconnect: num(r.interconnect), + interconnectType: str(r.interconnectType), + provisioningTime: num(r.provisioningTime), + stockStatus: str(r.stockStatus), + security: str(r.security), + prices: r.prices as PrimeGpuListing['prices'], + images: Array.isArray(r.images) ? (r.images as string[]) : undefined, + isSpot: typeof r.isSpot === 'boolean' ? r.isSpot : undefined, + prepaidTime: num(r.prepaidTime), + raw: r, + }; +} + +const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined); +const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined); diff --git a/packages/prime/src/index.ts b/packages/prime/src/index.ts new file mode 100644 index 0000000..ce46e7b --- /dev/null +++ b/packages/prime/src/index.ts @@ -0,0 +1,2 @@ +export * from './client'; +export * from './map'; diff --git a/packages/prime/src/map.ts b/packages/prime/src/map.ts new file mode 100644 index 0000000..ee4bcb3 --- /dev/null +++ b/packages/prime/src/map.ts @@ -0,0 +1,153 @@ +/** + * Mapping upstream availability listings into PIG's inventory rows. + * + * The schema was written to mirror the upstream field names, so this is mostly + * a rename rather than a transformation. The interesting parts are the two + * places where a judgement is required. + * + * **Money.** Upstream prices are floating-point dollars per hour. PIG stores + * integer cents, because these values feed margin reporting and floating-point + * currency in a system that reports margin is a defect waiting to be found by + * an accountant. Rounding happens exactly once, here, at the boundary. + * + * **Interconnect.** The single most commercially loaded field, since it decides + * whether capacity can train or only serve. Upstream sends free text with + * inconsistent casing, and an unrecognised value maps to `Unknown` rather than + * being optimistically read as Ethernet — claiming a cluster has no fast fabric + * when it does loses a deal, but the reverse sells a customer something that + * will not work. + */ +import type { InterconnectType, SecurityTier, StockStatus } from '@pig/core'; +import type { PrimeGpuListing } from './client'; + +export interface MappedListing { + externalCloudId: string | null; + providerSlug: string | null; + gpuType: string; + socket: string | null; + gpuCount: number; + gpuMemoryGb: number | null; + vcpu: number | null; + memoryGb: number | null; + diskGb: number | null; + internetMbps: number | null; + interconnectGbps: number | null; + interconnectType: InterconnectType; + region: string | null; + country: string | null; + securityTier: SecurityTier; + stockStatus: StockStatus; + isSpot: boolean; + provisioningMinutes: number | null; + prepaidHours: string | null; + onDemandPriceCents: number | null; + communityPriceCents: number | null; + priceIsVariable: boolean; + currency: string; + images: string[]; + raw: Record; +} + +export function mapListing(listing: PrimeGpuListing): MappedListing | null { + // Without a GPU type and a count there is nothing sellable to record. + if (!listing.gpuType || !listing.gpuCount) return null; + + return { + externalCloudId: listing.cloudId ?? null, + providerSlug: listing.provider ?? null, + gpuType: listing.gpuType, + socket: normaliseSocket(listing.socket), + gpuCount: listing.gpuCount, + gpuMemoryGb: listing.gpuMemory ?? null, + vcpu: unwrapCount(listing.vcpu), + memoryGb: unwrapCount(listing.memory), + diskGb: listing.disk?.defaultCount ?? null, + internetMbps: listing.internetSpeed ?? null, + interconnectGbps: listing.interconnect ?? null, + interconnectType: normaliseInterconnect(listing.interconnectType), + region: listing.region ?? null, + country: listing.country ?? null, + securityTier: listing.security === 'community_cloud' ? 'community_cloud' : 'secure_cloud', + stockStatus: normaliseStock(listing.stockStatus), + isSpot: listing.isSpot ?? false, + provisioningMinutes: listing.provisioningTime ?? null, + prepaidHours: listing.prepaidTime != null ? String(listing.prepaidTime) : null, + onDemandPriceCents: toCents(listing.prices?.onDemand), + communityPriceCents: toCents(listing.prices?.communityPrice), + priceIsVariable: listing.prices?.isVariable ?? false, + currency: listing.prices?.currency ?? 'USD', + images: listing.images ?? [], + raw: listing.raw, + }; +} + +/** + * Dollars to integer cents. + * + * Multiplying by 100 in floating point then truncating loses a cent on values + * like 2.43 (which is 2.4299999... in binary), so this rounds rather than + * truncates. At GPU-hour scale one cent compounds into real money across + * millions of hours. + */ +export function toCents(dollars: number | null | undefined): number | null { + if (dollars == null || !Number.isFinite(dollars)) return null; + return Math.round(dollars * 100); +} + +function unwrapCount(value: unknown): number | null { + if (typeof value === 'number') return value; + if (value && typeof value === 'object') { + const c = (value as { defaultCount?: unknown }).defaultCount; + if (typeof c === 'number') return c; + } + return null; +} + +function normaliseSocket(socket: string | undefined): string | null { + if (!socket) return null; + const upper = socket.toUpperCase().replace(/[\s_-]/g, ''); + if (upper === 'PCIE') return 'PCIe'; + const sxm = /^SXM([2-6])$/.exec(upper); + if (sxm) return `SXM${sxm[1]}`; + // Unknown sockets are dropped rather than stored, since the column is an + // enum. The verbatim value survives in `raw`. + return null; +} + +/** + * Interconnect, conservatively. + * + * `Unknown` is the safe default. Guessing Ethernet would understate real + * capacity; guessing InfiniBand would sell a training customer a cluster that + * cannot train. Neither error is acceptable, so an unrecognised value stays + * explicitly unknown and a human resolves it. + */ +function normaliseInterconnect(value: string | undefined): InterconnectType { + if (!value) return 'Unknown'; + const v = value.toLowerCase().replace(/[\s_-]/g, ''); + if (v.includes('infiniband') || v === 'ib') return 'Infiniband'; + if (v.includes('roce')) return 'RoCE'; + if (v.includes('nvlink') || v.includes('nvl')) return 'NVLink'; + if (v.includes('ethernet') || v.includes('eth')) return 'Ethernet'; + return 'Unknown'; +} + +function normaliseStock(value: string | undefined): StockStatus { + switch ((value ?? '').toLowerCase()) { + case 'available': + return 'Available'; + case 'low': + return 'Low'; + case 'medium': + return 'Medium'; + case 'high': + return 'High'; + case 'unavailable': + return 'Unavailable'; + default: + // An unknown stock signal is treated as unavailable rather than + // available: it is better to under-promise inventory than to have a + // seller offer capacity that turns out not to exist. + return 'Unavailable'; + } +} diff --git a/packages/prime/tsconfig.json b/packages/prime/tsconfig.json new file mode 100644 index 0000000..a552e34 --- /dev/null +++ b/packages/prime/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "noEmit": true }, + "include": ["src/**/*.ts"] +}