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

This commit is contained in:
2026-08-13 01:39:01 -07:00
parent bfd2f8d95a
commit 853bde2265
160 changed files with 61812 additions and 483 deletions
+72 -102
View File
@@ -15,7 +15,6 @@ import type { Database } from '@pig/db';
import {
accounts,
activities,
agentTasks,
allocations,
capacityCommitments,
contacts,
@@ -27,11 +26,9 @@ import {
} from '@pig/db';
import {
ACCENTS,
ACCOUNT_SIDES,
ACTIVITY_TYPES,
CUSTOMER_SEGMENTS,
DEMAND_STAGES,
SUPPLIER_TYPES,
SECURITY_TIERS,
SUPPLY_STAGES,
TEAMS,
THEME_MODES,
@@ -39,17 +36,48 @@ import {
isValidThemeMode,
} from '@pig/core';
import type { Config } from './lib/config';
import { AuthError, createAuthenticator, type Principal } from './lib/auth';
import {
AuthError,
createAuthenticator,
effectivePermissions,
type Principal,
} from './lib/auth';
import {
createConfiguredAuthProvider,
type AuthProvider,
} from './lib/auth-provider';
import { apiError } from './lib/mutation';
import { CapacityService } from './services/capacity';
import { createSignupRoute } from './routes/signup';
import { createRegisterRoute } from './routes/register';
import { createDemandStageMutation } from './routes/deals';
import { createFactsRoute } from './routes/facts';
import { createApiKeyRoutes } from './routes/api-keys';
import { createCapacityWriteRoutes } from './routes/capacity-writes';
import { createRecordRoutes } from './routes/records';
import { createImportRoutes } from './routes/imports';
import { createGoogleSheetsRoutes } from './routes/google-sheets';
import { createContractRoutes } from './routes/contracts';
import { createPiggyChatRoutes } from './routes/piggy-chat';
import { createAdminSettingsRoutes } from './routes/admin-settings';
import { createSlackRoutes, SLACK_CAPACITY_COMMAND_PATH } from './routes/slack';
import { createBuzzRoutes } from './routes/buzz';
import { createIntegrationSettingsRoutes } from './routes/integration-settings';
import { createNotionImportRoutes, NOTION_OAUTH_CALLBACK_PATH } from './routes/notion-import';
import { NotificationOutbox } from './services/notification-outbox';
type Env = { Variables: { principal: Principal } };
export function createApp(config: Config, db: Database) {
export function createApp(
config: Config,
db: Database,
authProvider: AuthProvider | null = createConfiguredAuthProvider(config),
runtime: { onPlatformSettingsChanged?: () => Promise<void> } = {},
) {
const app = new Hono<Env>();
const auth = createAuthenticator(config, db);
const auth = createAuthenticator(config, db, authProvider);
const capacity = new CapacityService(db);
const notifications = new NotificationOutbox(db);
if (!config.isProduction) app.use('*', logger());
@@ -68,7 +96,7 @@ export function createApp(config: Config, db: Database) {
* route that turns an authenticated stranger into a member — requiring
* membership to reach it would be circular. It verifies the token itself.
*/
app.route('/', createSignupRoute(config, db));
app.route('/', createSignupRoute(config, db, authProvider));
/*
* Registration. Also before the auth middleware, and necessarily so: the
@@ -111,6 +139,8 @@ export function createApp(config: Config, db: Database) {
path === '/api/config' ||
path === '/api/signup' ||
path === '/api/register'
|| path === SLACK_CAPACITY_COMMAND_PATH
|| path === NOTION_OAUTH_CALLBACK_PATH
) {
return next();
}
@@ -135,6 +165,7 @@ export function createApp(config: Config, db: Database) {
name: p.name,
isPlatformAdmin: p.isPlatformAdmin,
teams: p.teams,
permissions: effectivePermissions(p),
via: p.via,
});
});
@@ -175,6 +206,34 @@ export function createApp(config: Config, db: Database) {
return c.json(user ?? null);
});
app.route('/', createApiKeyRoutes(db));
app.route('/', createRecordRoutes(db, notifications));
app.route('/', createImportRoutes(db));
app.route('/', createNotionImportRoutes(config, db));
app.route('/', createGoogleSheetsRoutes(db, {
clientId: config.GOOGLE_CLIENT_ID,
clientSecret: config.GOOGLE_CLIENT_SECRET,
redirectUri: config.GOOGLE_REDIRECT_URI,
encryptionKey: config.PIG_SETTINGS_ENCRYPTION_KEY,
publicUrl: config.PIG_PUBLIC_URL,
}));
app.route('/', createContractRoutes(db));
app.route(
'/',
createPiggyChatRoutes({
enabled: config.PIGGY_ENABLED,
internalUrl: config.PIGGY_INTERNAL_URL,
internalToken: config.PIGGY_INTERNAL_TOKEN,
}),
);
app.route('/', createSlackRoutes(config, db, capacity));
if (config.BUZZ_RELAY_URL) app.route('/', createBuzzRoutes(db, config.BUZZ_RELAY_URL));
app.route('/', createIntegrationSettingsRoutes(config));
app.route(
'/',
createAdminSettingsRoutes(config, db, runtime.onPlatformSettingsChanged),
);
app.get('/api/team', async (c) => {
const rows = await db
.select({
@@ -263,54 +322,6 @@ export function createApp(config: Config, db: Database) {
});
});
// 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) => {
@@ -341,50 +352,9 @@ export function createApp(config: Config, db: Database) {
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);
});
app.patch('/api/deals/demand/:id/stage', createDemandStageMutation(db, notifications));
app.route('/', createCapacityWriteRoutes(db));
app.route('/', createFactsRoute(db));
// ------------------------------------------------------------- activities
@@ -460,7 +430,7 @@ export function createApp(config: Config, db: Database) {
gpuCount: z.number().int().positive(),
totalGpuHours: z.number().positive().optional(),
requiresHighSpeedInterconnect: z.boolean().optional(),
minSecurityTier: z.enum(['secure_cloud', 'community_cloud']).optional(),
minSecurityTier: z.enum(SECURITY_TIERS).optional(),
startsAt: z.string().datetime().optional(),
endsAt: z.string().datetime().optional(),
maxPricePerGpuHourCents: z.number().int().positive().optional(),
@@ -553,7 +523,7 @@ export function createApp(config: Config, db: Database) {
app.onError((error, c) => {
if (error instanceof AuthError) {
return c.json({ error: error.message, code: error.code }, error.status);
return c.json(apiError(error.code, error.message), error.status);
}
console.error('[pig] unhandled error', error);
// Never leak internals to a client; the detail is in the server log.
+43
View File
@@ -0,0 +1,43 @@
/**
* Authentication-provider boundary.
*
* Providers prove an external identity. They do not decide whether that
* identity belongs to PIG; workspace membership remains a database decision
* in the authenticator and signup route.
*/
import { createRemoteJWKSet, jwtVerify } from 'jose';
import type { Config } from './config';
export interface VerifiedIdentity {
subject: string;
email?: string;
}
export interface AuthProvider {
verifyAccessToken(token: string): Promise<VerifiedIdentity>;
}
export function createSupabaseAuthProvider(supabaseUrl: string): AuthProvider {
const issuer = `${supabaseUrl}/auth/v1`;
// `jose` fetches lazily and caches this set, including safe key rotation.
// Sharing one provider instance avoids a remote lookup path per handler.
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
return {
async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
const { payload } = await jwtVerify(token, jwks, { issuer });
if (!payload.sub) throw new Error('token has no subject');
return {
subject: payload.sub,
email: typeof payload.email === 'string' ? payload.email : undefined,
};
},
};
}
export function createConfiguredAuthProvider(
config: Pick<Config, 'SUPABASE_URL'>,
): AuthProvider | null {
return config.SUPABASE_URL ? createSupabaseAuthProvider(config.SUPABASE_URL) : null;
}
+70 -32
View File
@@ -4,9 +4,8 @@
* The distinction is the whole point of this file, and it is the thing most
* likely to be got wrong by someone extending PIG later:
*
* **Authentication** answers "who is this?" and is delegated to Supabase.
* PIG verifies the JWT against the project's JWKS. It stores no passwords
* and issues no sessions of its own.
* **Authentication** answers "who is this?" and is delegated to an identity
* provider. PIG stores no passwords and issues no sessions of its own.
*
* **Authorization** answers "may they use PIG?" and is answered ONLY by a row
* in PIG's `users` table.
@@ -20,13 +19,21 @@
* A token with no matching PIG user gets 403 with `needs_profile`, which the
* front end turns into the invite-redemption screen.
*/
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { eq } from 'drizzle-orm';
import type { Database } from '@pig/db';
import { apiKeys, teamMemberships, users } from '@pig/db';
import type { Team, TeamRole } from '@pig/core';
import {
permissionGranted,
resolvePermissionGrants,
type Capability,
type PermissionGrant,
type Team,
type TeamCapability,
type TeamRole,
} from '@pig/core';
import { createHash, timingSafeEqual } from 'node:crypto';
import type { Config } from './config';
import type { AuthProvider } from './auth-provider';
export interface Principal {
userId: string;
@@ -51,13 +58,11 @@ export class AuthError extends Error {
}
}
export function createAuthenticator(config: Config, db: Database) {
// The JWKS is fetched lazily and cached by `jose`, which also handles key
// rotation. Building it once avoids a fetch per request.
const jwks = config.SUPABASE_URL
? createRemoteJWKSet(new URL(`${config.SUPABASE_URL}/auth/v1/.well-known/jwks.json`))
: null;
export function createAuthenticator(
config: Config,
db: Database,
authProvider: AuthProvider | null,
) {
async function loadPrincipal(
userId: string,
via: Principal['via'],
@@ -95,13 +100,21 @@ export function createAuthenticator(config: Config, db: Database) {
* Accepts either a Supabase JWT or a PIG API key, both in the
* Authorization header. API keys exist so that an agent acting for a person
* is a distinct principal from that person — separately revocable, with its
* own audit trail and its own scopes.
* own audit trail and its own scopes.
*/
async authenticate(header: string | undefined): Promise<Principal> {
const token = header?.startsWith('Bearer ') ? header.slice(7).trim() : null;
// An explicit PIG key must be honoured even in development. Otherwise a
// revoked or malformed key silently becomes the development user, which
// makes local integration tests pass without testing the credential at
// all and hides the exact failures developers need to see.
if (token?.startsWith('pig_')) return authenticateApiKey(token);
// Development escape hatch. Guarded three ways, and `loadConfig` refuses
// to start in production without Supabase, so this cannot leak into a
// real deployment.
if (!config.SUPABASE_URL && !config.isProduction) {
// to start in production without identity configuration, so this cannot
// leak into a real deployment.
if (!authProvider && !config.isProduction) {
const [devUser] = await db.select().from(users).limit(1);
if (!devUser) {
throw new AuthError(
@@ -116,22 +129,14 @@ export function createAuthenticator(config: Config, db: Database) {
if (!header?.startsWith('Bearer ')) {
throw new AuthError('Missing bearer token.', 401, 'no_token');
}
const token = header.slice(7).trim();
// PIG-issued API keys carry a recognisable prefix, so we can route
// without attempting an expensive and pointless JWT verification.
if (token.startsWith('pig_')) return authenticateApiKey(token);
if (!jwks) throw new AuthError('Authentication is not configured.', 401, 'no_jwks');
if (!authProvider) {
throw new AuthError('Authentication is not configured.', 401, 'no_jwks');
}
let subject: string;
try {
const { payload } = await jwtVerify(token, jwks, {
// Supabase signs with the project URL as issuer.
issuer: `${config.SUPABASE_URL}/auth/v1`,
});
if (!payload.sub) throw new Error('token has no subject');
subject = payload.sub;
subject = (await authProvider.verifyAccessToken(token!)).subject;
} catch {
// Deliberately opaque: distinguishing "expired" from "malformed" from
// "wrong issuer" tells an attacker which knob to turn.
@@ -167,10 +172,7 @@ export function createAuthenticator(config: Config, db: Database) {
.limit(1);
if (!record) throw new AuthError('Unknown API key.', 401, 'invalid_key');
if (record.revokedAt) throw new AuthError('This API key was revoked.', 401, 'revoked_key');
if (record.expiresAt && record.expiresAt < new Date()) {
throw new AuthError('This API key has expired.', 401, 'expired_key');
}
assertApiKeyActive(record);
// Best-effort last-used stamp. Never block the request on it: a failed
// bookkeeping write must not deny access.
@@ -187,6 +189,16 @@ export function createAuthenticator(config: Config, db: Database) {
}
}
export function assertApiKeyActive(
record: { revokedAt: Date | null; expiresAt: Date | null },
now = new Date(),
): void {
if (record.revokedAt) throw new AuthError('This API key was revoked.', 401, 'revoked_key');
if (record.expiresAt && record.expiresAt < now) {
throw new AuthError('This API key has expired.', 401, 'expired_key');
}
}
/**
* Hash an API key for storage and lookup.
*
@@ -227,3 +239,29 @@ export function requireScope(principal: Principal, scope: string): void {
if (principal.scopes.includes(scope)) return;
throw new AuthError(`This credential lacks the '${scope}' scope.`, 403, 'insufficient_scope');
}
/** Effective grants include credential scope, not merely the owner's roles. */
export function effectivePermissions(principal: Principal): PermissionGrant[] {
if (!principal.scopes.includes('write')) return [];
return resolvePermissionGrants(principal);
}
export function requireCapability(principal: Principal, capability: Capability): void;
export function requireCapability(
principal: Principal,
capability: TeamCapability,
team: Team,
): void;
export function requireCapability(
principal: Principal,
capability: Capability,
team?: Team,
): void {
requireScope(principal, 'write');
if (permissionGranted(resolvePermissionGrants(principal), capability, team)) return;
throw new AuthError(
`This principal lacks the '${capability}' capability${team ? ` for ${team}` : ''}.`,
403,
'insufficient_permission',
);
}
+119 -4
View File
@@ -26,6 +26,9 @@ const envBoolean = (defaultValue: boolean) =>
return ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase());
});
const optionalEnvString = (value: unknown) =>
typeof value === 'string' && value.trim() === '' ? undefined : value;
const schema = z.object({
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'),
@@ -45,16 +48,126 @@ const schema = z.object({
PRIME_SYNC_ENABLED: envBoolean(false),
PRIME_SYNC_INTERVAL_MINUTES: z.coerce.number().int().positive().default(30),
/** Base64-encoded 32-byte key. Secrets written in the admin UI require it. */
PIG_SETTINGS_ENCRYPTION_KEY: z.string().optional(),
PIGGY_ENABLED: envBoolean(false),
ANTHROPIC_API_KEY: z.string().optional(),
PIGGY_MODEL: z.string().default('claude-sonnet-5'),
PIGGY_MODEL: z.string().default('nvidia/nemotron-3-nano-30b-a3b'),
PIGGY_INFERENCE_BASE: z.string().url().default('https://api.pinference.ai/api/v1'),
PIGGY_LEASE_SECONDS: z.coerce.number().int().positive().default(300),
PIGGY_INTERNAL_URL: z.preprocess(
(value) => (value === '' ? undefined : value),
z.string().url().optional(),
),
PIGGY_INTERNAL_TOKEN: z.preprocess(
(value) => (value === '' ? undefined : value),
z.string().min(32).optional(),
),
SLACK_BOT_TOKEN: z.string().optional(),
SLACK_SIGNING_SECRET: z.string().optional(),
BUZZ_RELAY_URL: z.string().optional(),
BUZZ_RELAY_URL: z.preprocess(optionalEnvString, z.string().url().optional()),
BUZZ_PRIVATE_KEY: z.preprocess(optionalEnvString, z.string().min(1).optional()),
BUZZ_AUTH_TAG: z.preprocess(optionalEnvString, z.string().min(1).optional()),
NOTION_CLIENT_ID: z.preprocess(optionalEnvString, z.string().min(1).optional()),
NOTION_CLIENT_SECRET: z.preprocess(optionalEnvString, z.string().min(1).optional()),
NOTION_REDIRECT_URI: z.preprocess(optionalEnvString, z.string().url().optional()),
GOOGLE_CLIENT_ID: z.preprocess(optionalEnvString, z.string().min(1).optional()),
GOOGLE_CLIENT_SECRET: z.preprocess(optionalEnvString, z.string().min(1).optional()),
GOOGLE_REDIRECT_URI: z.preprocess(optionalEnvString, z.string().url().optional()),
}).superRefine((value, context) => {
const buzzConfigured = Boolean(
value.BUZZ_RELAY_URL || value.BUZZ_PRIVATE_KEY || value.BUZZ_AUTH_TAG,
);
if (buzzConfigured && !value.BUZZ_RELAY_URL) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['BUZZ_RELAY_URL'],
message: 'BUZZ_RELAY_URL is required when Buzz delivery is configured.',
});
}
if (buzzConfigured && !value.BUZZ_PRIVATE_KEY) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['BUZZ_PRIVATE_KEY'],
message: 'BUZZ_PRIVATE_KEY is required when Buzz delivery is configured.',
});
}
const notionConfigured = Boolean(
value.NOTION_CLIENT_ID || value.NOTION_CLIENT_SECRET || value.NOTION_REDIRECT_URI,
);
for (const key of ['NOTION_CLIENT_ID', 'NOTION_CLIENT_SECRET', 'NOTION_REDIRECT_URI'] as const) {
if (notionConfigured && !value[key]) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: `${key} is required when Notion import is configured.`,
});
}
}
if (notionConfigured && !hasValidEncryptionKey(value.PIG_SETTINGS_ENCRYPTION_KEY)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['PIG_SETTINGS_ENCRYPTION_KEY'],
message: 'A base64-encoded 32-byte PIG_SETTINGS_ENCRYPTION_KEY is required for Notion OAuth.',
});
}
const googleConfigured = Boolean(
value.GOOGLE_CLIENT_ID || value.GOOGLE_CLIENT_SECRET || value.GOOGLE_REDIRECT_URI,
);
for (const key of ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET', 'GOOGLE_REDIRECT_URI'] as const) {
if (googleConfigured && !value[key]) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: [key],
message: `${key} is required when Google Sheets import is configured.`,
});
}
}
if (googleConfigured && !hasValidEncryptionKey(value.PIG_SETTINGS_ENCRYPTION_KEY)) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['PIG_SETTINGS_ENCRYPTION_KEY'],
message: 'A base64-encoded 32-byte PIG_SETTINGS_ENCRYPTION_KEY is required for Google OAuth.',
});
}
if (value.GOOGLE_REDIRECT_URI) {
try {
const redirect = new URL(value.GOOGLE_REDIRECT_URI);
const publicUrl = new URL(value.PIG_PUBLIC_URL);
if (
redirect.origin !== publicUrl.origin
|| redirect.pathname !== '/oauth/google/callback'
|| redirect.search
|| redirect.hash
) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['GOOGLE_REDIRECT_URI'],
message: 'GOOGLE_REDIRECT_URI must use the PIG_PUBLIC_URL origin and exact /oauth/google/callback path.',
});
}
} catch {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['GOOGLE_REDIRECT_URI'],
message: 'GOOGLE_REDIRECT_URI must use the PIG_PUBLIC_URL origin and exact /oauth/google/callback path.',
});
}
}
});
function hasValidEncryptionKey(value: string | undefined): boolean {
if (!value) return false;
const decoded = Buffer.from(value, 'base64');
return decoded.length === 32
&& decoded.toString('base64').replace(/=+$/, '') === value.replace(/=+$/, '');
}
export type Config = z.infer<typeof schema> & {
adminEmails: string[];
isProduction: boolean;
@@ -108,8 +221,10 @@ function warnOnFootguns(config: Config): void {
warn('PRIME_SYNC_ENABLED is on but PRIME_API_KEY is unset — sync will not run.');
}
if (config.PIGGY_ENABLED && !config.ANTHROPIC_API_KEY) {
warn('PIGGY_ENABLED is on but ANTHROPIC_API_KEY is unset — the agent will idle.');
if (config.PIGGY_ENABLED && (!config.PIGGY_INTERNAL_URL || !config.PIGGY_INTERNAL_TOKEN)) {
warn(
'PIGGY_ENABLED is on but the internal URL or token is unset — interactive chat will be unavailable.',
);
}
if (config.SUPABASE_SERVICE_KEY) {
+194
View File
@@ -0,0 +1,194 @@
import type { ActivityType, GlobalCapability, Team, TeamCapability } from '@pig/core';
import type { Database } from '@pig/db';
import { activities } from '@pig/db';
import type { Context, Handler } from 'hono';
import type { ZodIssue, ZodTypeAny, infer as Infer } from 'zod';
import { requireCapability, type Principal } from './auth';
export type ApiEnv = { Variables: { principal: Principal } };
export interface ApiErrorEnvelope {
error: string;
code: string;
issues?: ZodIssue[];
}
export function apiError(
code: string,
error: string,
issues?: ZodIssue[],
): ApiErrorEnvelope {
return issues ? { error, code, issues } : { error, code };
}
export class MutationError extends Error {
constructor(
readonly code: string,
message: string,
readonly status: 400 | 404 | 409 | 502 | 503,
readonly issues?: ZodIssue[],
) {
super(message);
this.name = 'MutationError';
}
static notFound(resource: string): MutationError {
return new MutationError('not_found', `${resource} not found.`, 404);
}
}
type PermissionRequirement =
| { capability: GlobalCapability; team?: never }
| { capability: TeamCapability; team: Team }
| { authorize(principal: Principal): void };
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0];
export interface MutationActivity {
type: ActivityType;
subject: string;
body?: string;
accountId?: string;
contactId?: string;
demandDealId?: string;
supplyDealId?: string;
meta?: Record<string, unknown>;
}
export interface MutationResult<Result> {
data: Result;
activity: MutationActivity;
}
interface MutationContext<Input> {
input: Input;
principal: Principal;
params: Readonly<Record<string, string>>;
tx: Transaction;
now: Date;
}
export interface MutationDefinition<Schema extends ZodTypeAny, Result> {
schema: Schema;
permission: PermissionRequirement;
invalidMessage: string;
mutate(context: MutationContext<Infer<Schema>>): Promise<MutationResult<Result>>;
}
function enforcePermission(principal: Principal, permission: PermissionRequirement): void {
if ('authorize' in permission) {
permission.authorize(principal);
return;
}
if (permission.capability === 'settings:admin') {
requireCapability(principal, permission.capability);
return;
}
requireCapability(principal, permission.capability, permission.team);
}
/**
* Runs an authorised, validated write and its audit event atomically.
*
* Permission precedes body parsing so a caller cannot probe validation rules
* for a write they are not allowed to perform. Validation precedes the
* transaction so bad input never consumes a connection or leaves audit noise.
*/
export async function executeMutation<Schema extends ZodTypeAny, Result>(
db: Database,
principal: Principal,
readInput: () => Promise<unknown>,
definition: MutationDefinition<Schema, Result>,
params: Readonly<Record<string, string>> = {},
): Promise<Result> {
enforcePermission(principal, definition.permission);
let rawInput: unknown;
try {
rawInput = await readInput();
} catch {
throw new MutationError('invalid_json', 'Request body must be valid JSON.', 400);
}
const parsed = definition.schema.safeParse(rawInput);
if (!parsed.success) {
throw new MutationError(
'invalid_request',
definition.invalidMessage,
400,
parsed.error.issues,
);
}
return db.transaction(async (tx) => {
const now = new Date();
const context: MutationContext<Infer<Schema>> = {
input: parsed.data,
principal,
params,
tx,
now,
};
const result = await definition.mutate(context);
await tx.insert(activities).values({
...result.activity,
actorUserId: principal.userId,
actorAgent: principal.via === 'api_key' ? 'agent' : null,
source: principal.via === 'api_key' ? 'agent' : 'manual',
occurredAt: now,
});
return result.data;
});
}
export function mutation<Schema extends ZodTypeAny, Result>(
db: Database,
definition: MutationDefinition<Schema, Result>,
): Handler<ApiEnv> {
return async (c: Context<ApiEnv>) => {
try {
const result = await executeMutation(
db,
c.get('principal'),
() => c.req.json(),
definition,
c.req.param(),
);
return c.json(result);
} catch (error) {
if (error instanceof MutationError) {
return c.json(apiError(error.code, error.message, error.issues), error.status);
}
throw error;
}
};
}
/**
* DELETE-style writes still use the mutation convention without making clients
* send a meaningless JSON body. The empty input is deliberate: authorization
* still runs first, and route parameters remain request-local.
*/
export function bodylessMutation<Schema extends ZodTypeAny, Result>(
db: Database,
definition: MutationDefinition<Schema, Result>,
): Handler<ApiEnv> {
return async (c: Context<ApiEnv>) => {
try {
const result = await executeMutation(
db,
c.get('principal'),
async () => ({}),
definition,
c.req.param(),
);
return c.json(result);
} catch (error) {
if (error instanceof MutationError) {
return c.json(apiError(error.code, error.message, error.issues), error.status);
}
throw error;
}
};
}
+75
View File
@@ -0,0 +1,75 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const DEFAULT_SECRET_PURPOSE = 'platform-settings:prime-api-key';
function additionalAuthenticatedData(purpose: string): Buffer {
return Buffer.from(`pig:${purpose}:v1`);
}
export class SecretConfigurationError extends Error {
constructor(message: string) {
super(message);
this.name = 'SecretConfigurationError';
}
}
function masterKey(value: string | undefined): Buffer {
if (!value) {
throw new SecretConfigurationError(
'PIG_SETTINGS_ENCRYPTION_KEY is required before credentials can be stored.',
);
}
const key = Buffer.from(value, 'base64');
if (key.length !== 32 || key.toString('base64').replace(/=+$/, '') !== value.replace(/=+$/, '')) {
throw new SecretConfigurationError(
'PIG_SETTINGS_ENCRYPTION_KEY must be a base64-encoded 32-byte key.',
);
}
return key;
}
export function encryptionReady(value: string | undefined): boolean {
try {
masterKey(value);
return true;
} catch {
return false;
}
}
export function encryptSecret(
plaintext: string,
keyValue: string | undefined,
purpose = DEFAULT_SECRET_PURPOSE,
): string {
const key = masterKey(keyValue);
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
cipher.setAAD(additionalAuthenticatedData(purpose));
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return ['v1', iv.toString('base64url'), tag.toString('base64url'), ciphertext.toString('base64url')].join('.');
}
export function decryptSecret(
envelope: string,
keyValue: string | undefined,
purpose = DEFAULT_SECRET_PURPOSE,
): string {
const [version, ivValue, tagValue, ciphertextValue, extra] = envelope.split('.');
if (version !== 'v1' || !ivValue || !tagValue || !ciphertextValue || extra) {
throw new SecretConfigurationError('Stored credential has an unsupported format.');
}
try {
const decipher = createDecipheriv('aes-256-gcm', masterKey(keyValue), Buffer.from(ivValue, 'base64url'));
decipher.setAAD(additionalAuthenticatedData(purpose));
decipher.setAuthTag(Buffer.from(tagValue, 'base64url'));
return Buffer.concat([
decipher.update(Buffer.from(ciphertextValue, 'base64url')),
decipher.final(),
]).toString('utf8');
} catch (error) {
if (error instanceof SecretConfigurationError) throw error;
throw new SecretConfigurationError('Stored credential could not be decrypted.');
}
}
+428
View File
@@ -0,0 +1,428 @@
import { TEAM_ROLES, TEAMS } from '@pig/core';
import type { Database, PlatformSettings } from '@pig/db';
import { invites, platformSettings, teamMemberships, users } from '@pig/db';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { Hono } from 'hono';
import { createHash, randomBytes } from 'node:crypto';
import { z } from 'zod';
import { requireCapability } from '../lib/auth';
import type { Config } from '../lib/config';
import {
apiError,
bodylessMutation,
MutationError,
mutation,
type ApiEnv,
} from '../lib/mutation';
import {
decryptSecret,
encryptionReady,
encryptSecret,
SecretConfigurationError,
} from '../lib/secrets';
const SETTINGS_ID = 'default';
export function normaliseInferenceEndpoint(value: string): string {
return value.replace(/\/+$/, '');
}
export function isInferenceEndpoint(value: string): boolean {
try {
return new URL(value).hostname !== 'api.primeintellect.ai';
} catch {
return false;
}
}
export const platformSettingsSchema = z
.object({
piggyModel: z.string().trim().min(1).max(200).optional(),
piggyInferenceBase: z
.string()
.url()
.transform(normaliseInferenceEndpoint)
.refine(isInferenceEndpoint, 'Inference must not use the Prime compute API host.')
.optional(),
piggyEnabled: z.boolean().optional(),
primeApiKey: z.string().trim().min(16).max(1000).optional(),
clearPrimeApiKey: z.boolean().optional(),
primeSyncEnabled: z.boolean().optional(),
primeSyncIntervalMinutes: z.number().int().min(1).max(1440).optional(),
})
.strict()
.refine((value) => Object.values(value).some((item) => item !== undefined), 'No settings supplied.')
.refine(
(value) => !(value.primeApiKey && value.clearPrimeApiKey),
'A credential cannot be set and cleared together.',
);
export const inviteCreateSchema = z
.object({
email: z.string().trim().email().max(200).optional(),
team: z.enum(TEAMS).optional(),
role: z.enum(TEAM_ROLES).default('member'),
expiresAt: z.string().datetime().optional(),
usesRemaining: z.number().int().min(1).max(100).default(1),
})
.strict();
export const memberAccessSchema = z
.object({
isPlatformAdmin: z.boolean(),
memberships: z
.array(
z.object({
team: z.enum(TEAMS),
role: z.enum(TEAM_ROLES),
}),
)
.max(TEAMS.length)
.refine(
(memberships) => new Set(memberships.map(({ team }) => team)).size === memberships.length,
'Each team may appear only once.',
),
})
.strict();
function initialSettings(config: Config) {
return {
id: SETTINGS_ID,
piggyModel: config.PIGGY_MODEL,
piggyInferenceBase: normaliseInferenceEndpoint(config.PIGGY_INFERENCE_BASE),
piggyEnabled: config.PIGGY_ENABLED,
primeSyncEnabled: config.PRIME_SYNC_ENABLED,
primeSyncIntervalMinutes: config.PRIME_SYNC_INTERVAL_MINUTES,
};
}
export async function ensurePlatformSettings(config: Config, db: Database): Promise<PlatformSettings> {
await db.insert(platformSettings).values(initialSettings(config)).onConflictDoNothing();
const [row] = await db
.select()
.from(platformSettings)
.where(eq(platformSettings.id, SETTINGS_ID))
.limit(1);
if (!row) throw new Error('Platform settings row is missing');
return row;
}
export function platformSettingsResponse(row: PlatformSettings, config: Config) {
const storedCredential = Boolean(row.primeApiKeyEncrypted);
const environmentCredential = Boolean(config.PRIME_API_KEY);
return {
piggyModel: row.piggyModel,
piggyInferenceBase: row.piggyInferenceBase,
piggyEnabled: row.piggyEnabled,
primeComputeBase: config.PRIME_API_BASE,
primeApiKey: {
configured: storedCredential || environmentCredential,
source: storedCredential ? 'database' : environmentCredential ? 'environment' : null,
updatedAt: storedCredential ? row.primeApiKeyUpdatedAt : null,
encryptionReady: encryptionReady(config.PIG_SETTINGS_ENCRYPTION_KEY),
},
primeSyncEnabled: row.primeSyncEnabled,
primeSyncIntervalMinutes: row.primeSyncIntervalMinutes,
updatedAt: row.updatedAt,
};
}
export async function effectivePrimeSyncConfig(config: Config, db: Database): Promise<Config> {
const row = await ensurePlatformSettings(config, db);
const storedKey = row.primeApiKeyEncrypted
? decryptSecret(row.primeApiKeyEncrypted, config.PIG_SETTINGS_ENCRYPTION_KEY)
: undefined;
return {
...config,
PRIME_API_KEY: storedKey ?? config.PRIME_API_KEY,
PRIME_SYNC_ENABLED: row.primeSyncEnabled,
PRIME_SYNC_INTERVAL_MINUTES: row.primeSyncIntervalMinutes,
};
}
type InviteRow = typeof invites.$inferSelect;
export function inviteMetadata(row: InviteRow, now = new Date()) {
const status = row.revokedAt
? 'revoked'
: row.usesRemaining < 1
? 'used'
: row.expiresAt && row.expiresAt <= now
? 'expired'
: 'active';
return {
id: row.id,
email: row.email,
team: row.team,
role: row.role,
usesRemaining: row.usesRemaining,
expiresAt: row.expiresAt,
redeemedAt: row.redeemedAt,
revokedAt: row.revokedAt,
createdAt: row.createdAt,
status,
};
}
function generateInviteCode(): string {
return `pig_inv_${randomBytes(24).toString('base64url')}`;
}
export function createAdminSettingsRoutes(
config: Config,
db: Database,
onSettingsChanged?: () => Promise<void>,
) {
const app = new Hono<ApiEnv>();
app.get('/api/admin/settings', async (c) => {
requireCapability(c.get('principal'), 'settings:admin');
return c.json(platformSettingsResponse(await ensurePlatformSettings(config, db), config));
});
const updateSettings = mutation(db, {
schema: platformSettingsSchema,
permission: { capability: 'settings:admin' },
invalidMessage: 'Invalid platform settings.',
async mutate({ input, principal, tx, now }) {
const set: Partial<typeof platformSettings.$inferInsert> = {
updatedAt: now,
updatedByUserId: principal.userId,
};
if (input.piggyModel !== undefined) set.piggyModel = input.piggyModel;
if (input.piggyInferenceBase !== undefined) set.piggyInferenceBase = input.piggyInferenceBase;
if (input.piggyEnabled !== undefined) set.piggyEnabled = input.piggyEnabled;
if (input.primeSyncEnabled !== undefined) set.primeSyncEnabled = input.primeSyncEnabled;
if (input.primeSyncIntervalMinutes !== undefined) {
set.primeSyncIntervalMinutes = input.primeSyncIntervalMinutes;
}
if (input.primeApiKey !== undefined) {
try {
set.primeApiKeyEncrypted = encryptSecret(
input.primeApiKey,
config.PIG_SETTINGS_ENCRYPTION_KEY,
);
} catch (error) {
if (error instanceof SecretConfigurationError) {
throw new MutationError('encryption_unavailable', error.message, 409);
}
throw error;
}
set.primeApiKeyUpdatedAt = now;
} else if (input.clearPrimeApiKey) {
set.primeApiKeyEncrypted = null;
set.primeApiKeyUpdatedAt = null;
}
const [updated] = await tx
.insert(platformSettings)
.values({ ...initialSettings(config), ...set })
.onConflictDoUpdate({ target: platformSettings.id, set })
.returning();
if (!updated) throw new Error('Platform settings update returned no row');
return {
data: platformSettingsResponse(updated, config),
activity: {
type: 'agent_action',
subject: 'Updated platform settings',
meta: {
action: 'platform_settings.updated',
fields: Object.keys(input),
primeApiKeyChanged: input.primeApiKey !== undefined || Boolean(input.clearPrimeApiKey),
},
},
};
},
});
app.patch('/api/admin/settings', async (c, next) => {
const response = await updateSettings(c, next);
if (response?.ok && onSettingsChanged) {
void onSettingsChanged().catch((error: unknown) =>
console.error(
'[pig] platform settings saved but runtime reload failed:',
error instanceof Error ? error.message : 'unknown error',
),
);
}
return response;
});
app.get('/api/admin/invites', async (c) => {
requireCapability(c.get('principal'), 'settings:admin');
const rows = await db.select().from(invites).orderBy(desc(invites.createdAt));
return c.json(rows.map((row) => inviteMetadata(row)));
});
app.post(
'/api/admin/invites',
mutation(db, {
schema: inviteCreateSchema,
permission: { capability: 'settings:admin' },
invalidMessage: 'Invalid invite.',
async mutate({ input, principal, tx, now }) {
const expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;
if (expiresAt && expiresAt <= now) {
throw new MutationError('invalid_expiry', 'Invite expiry must be in the future.', 400);
}
const code = generateInviteCode();
const [created] = await tx
.insert(invites)
.values({
codeHash: createHash('sha256').update(code).digest('hex'),
email: input.email?.toLowerCase(),
team: input.team,
role: input.role,
expiresAt,
usesRemaining: input.usesRemaining,
createdByUserId: principal.userId,
scopeNote: 'admin-ui',
})
.returning();
if (!created) throw new Error('Invite insert returned no row');
return {
data: { ...inviteMetadata(created, now), code },
activity: {
type: 'agent_action',
subject: `Created${created.email ? ` invite for ${created.email}` : ' workspace invite'}`,
meta: {
action: 'invite.created',
inviteId: created.id,
email: created.email,
team: created.team,
role: created.role,
uses: created.usesRemaining,
},
},
};
},
}),
);
app.delete(
'/api/admin/invites/:id',
bodylessMutation(db, {
schema: z.object({}).strict(),
permission: { capability: 'settings:admin' },
invalidMessage: 'Invalid invite revocation.',
async mutate({ params, tx, now }) {
const id = params.id;
if (!id) throw MutationError.notFound('Invite');
const [existing] = await tx.select().from(invites).where(eq(invites.id, id)).limit(1);
if (!existing) throw MutationError.notFound('Invite');
const [updated] = existing.revokedAt
? [existing]
: await tx.update(invites).set({ revokedAt: now }).where(eq(invites.id, id)).returning();
if (!updated) throw MutationError.notFound('Invite');
return {
data: inviteMetadata(updated, now),
activity: {
type: 'agent_action',
subject: `Revoked${existing.email ? ` invite for ${existing.email}` : ' workspace invite'}`,
meta: {
action: 'invite.revoked',
inviteId: existing.id,
alreadyRevoked: existing.revokedAt !== null,
},
},
};
},
}),
);
app.get('/api/admin/members', async (c) => {
requireCapability(c.get('principal'), 'settings:admin');
const [people, memberships] = await Promise.all([
db.select().from(users).where(isNull(users.deactivatedAt)).orderBy(users.name),
db.select().from(teamMemberships),
]);
return c.json(
people.map((person) => ({
id: person.id,
name: person.name,
email: person.email,
title: person.title,
isPlatformAdmin:
person.isPlatformAdmin || config.adminEmails.includes(person.email.toLowerCase()),
adminSource: config.adminEmails.includes(person.email.toLowerCase())
? 'environment'
: person.isPlatformAdmin
? 'database'
: null,
memberships: memberships
.filter(({ userId }) => userId === person.id)
.map(({ team, role }) => ({ team, role })),
})),
);
});
app.patch(
'/api/admin/members/:id/access',
mutation(db, {
schema: memberAccessSchema,
permission: { capability: 'settings:admin' },
invalidMessage: 'Invalid member access.',
async mutate({ input, principal, params, tx, now }) {
const id = params.id;
if (!id) throw MutationError.notFound('Member');
const [person] = await tx.select().from(users).where(eq(users.id, id)).limit(1);
if (!person || person.deactivatedAt) throw MutationError.notFound('Member');
const environmentAdmin = config.adminEmails.includes(person.email.toLowerCase());
if (!input.isPlatformAdmin && environmentAdmin) {
throw new MutationError(
'environment_admin',
'This administrator is pinned by PIG_ADMIN_EMAILS and must be changed in deployment configuration.',
409,
);
}
if (!input.isPlatformAdmin && id === principal.userId) {
throw new MutationError(
'self_demotion',
'Ask another platform administrator to remove your access.',
409,
);
}
await tx
.update(users)
.set({ isPlatformAdmin: input.isPlatformAdmin, updatedAt: now })
.where(eq(users.id, id));
await tx.delete(teamMemberships).where(eq(teamMemberships.userId, id));
if (input.memberships.length > 0) {
await tx.insert(teamMemberships).values(
input.memberships.map((membership, index) => ({
userId: id,
...membership,
isPrimary: index === 0,
})),
);
}
const data = {
id,
name: person.name,
email: person.email,
title: person.title,
isPlatformAdmin: input.isPlatformAdmin || environmentAdmin,
adminSource: environmentAdmin ? 'environment' : input.isPlatformAdmin ? 'database' : null,
memberships: input.memberships,
};
return {
data,
activity: {
type: 'agent_action',
subject: `Updated access for ${person.name}`,
meta: {
action: 'member_access.updated',
targetUserId: id,
isPlatformAdmin: data.isPlatformAdmin,
memberships: input.memberships,
},
},
};
},
}),
);
return app;
}
+241
View File
@@ -0,0 +1,241 @@
import { API_KEY_SCOPES } from '@pig/core';
import type { Database } from '@pig/db';
import { apiKeys, users } from '@pig/db';
import { and, desc, eq, isNull } from 'drizzle-orm';
import { Hono } from 'hono';
import { randomBytes } from 'node:crypto';
import { z } from 'zod';
import { AuthError, hashApiKey, type Principal } from '../lib/auth';
import {
apiError,
bodylessMutation,
MutationError,
mutation,
type ApiEnv,
} from '../lib/mutation';
const apiKeyScopesSchema = z
.array(z.enum(API_KEY_SCOPES))
.min(1)
.max(API_KEY_SCOPES.length)
.refine((scopes) => scopes.includes('read'), 'Every API key must include the read scope.')
.refine((scopes) => new Set(scopes).size === scopes.length, 'Scopes must be unique.');
export const apiKeyCreateSchema = z
.object({
name: z.string().trim().min(1).max(120),
scopes: apiKeyScopesSchema.default(['read']),
expiresAt: z.string().datetime().optional(),
userId: z.string().uuid().optional(),
})
.strict();
const apiKeyListSchema = z
.object({
userId: z.string().uuid().optional(),
})
.strict();
const revokeApiKeySchema = z.object({}).strict();
type ApiKeyRow = typeof apiKeys.$inferSelect;
export interface GeneratedApiKey {
key: string;
keyHash: string;
keyPrefix: string;
}
export function generateApiKey(): GeneratedApiKey {
const key = `pig_${randomBytes(32).toString('base64url')}`;
return {
key,
keyHash: hashApiKey(key),
// Six random characters identify the credential without materially
// reducing its 256-bit search space if a key list is exposed.
keyPrefix: key.slice(0, 10),
};
}
export function apiKeyMetadata(row: ApiKeyRow) {
return {
id: row.id,
userId: row.userId,
name: row.name,
keyPrefix: row.keyPrefix,
scopes: row.scopes,
lastUsedAt: row.lastUsedAt,
expiresAt: row.expiresAt,
revokedAt: row.revokedAt,
createdAt: row.createdAt,
};
}
export function apiKeyCreationResponse(row: ApiKeyRow, key: string) {
return { ...apiKeyMetadata(row), key };
}
/** A credential may never mint a successor that outlives its own revocation. */
export function requireApiKeyManagement(principal: Principal): void {
if (principal.via !== 'api_key') return;
throw new AuthError(
'API keys cannot create, list, or revoke credentials.',
403,
'credential_management_forbidden',
);
}
export function resolveApiKeyTarget(principal: Principal, requestedUserId?: string): string {
requireApiKeyManagement(principal);
if (!requestedUserId || requestedUserId === principal.userId) return principal.userId;
if (principal.isPlatformAdmin) return requestedUserId;
throw new AuthError(
'Only a platform administrator may manage another user\'s API keys.',
403,
'insufficient_permission',
);
}
export function createApiKeyRoutes(db: Database) {
const app = new Hono<ApiEnv>();
app.get('/api/api-keys', async (c) => {
const principal = c.get('principal');
requireApiKeyManagement(principal);
const parsed = apiKeyListSchema.safeParse({ userId: c.req.query('userId') });
if (!parsed.success) {
return c.json(
apiError('invalid_request', 'Invalid API key query.', parsed.error.issues),
400,
);
}
const userId = resolveApiKeyTarget(principal, parsed.data.userId);
const [owner] = await db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1);
if (!owner) return c.json(apiError('not_found', 'User not found.'), 404);
const rows = await db
.select()
.from(apiKeys)
.where(eq(apiKeys.userId, userId))
.orderBy(desc(apiKeys.createdAt));
return c.json(rows.map(apiKeyMetadata));
});
app.post(
'/api/api-keys',
mutation(db, {
schema: apiKeyCreateSchema,
permission: { authorize: requireApiKeyManagement },
invalidMessage: 'Invalid API key.',
async mutate({ input, principal, tx, now }) {
const userId = resolveApiKeyTarget(principal, input.userId);
const [owner] = await tx
.select({ id: users.id })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!owner) throw MutationError.notFound('User');
const expiresAt = input.expiresAt ? new Date(input.expiresAt) : null;
if (expiresAt && expiresAt <= now) {
throw new MutationError(
'invalid_expiry',
'API key expiry must be in the future.',
400,
);
}
const material = generateApiKey();
const scopes = input.scopes.includes('write') ? ['read', 'write'] : ['read'];
const [created] = await tx
.insert(apiKeys)
.values({
userId,
name: input.name,
keyHash: material.keyHash,
keyPrefix: material.keyPrefix,
scopes,
expiresAt,
})
.returning();
if (!created) throw new Error('API key insert returned no row');
return {
data: apiKeyCreationResponse(created, material.key),
activity: {
type: 'agent_action',
subject: `Created API key “${created.name}`,
meta: {
action: 'api_key.created',
apiKeyId: created.id,
targetUserId: userId,
keyPrefix: created.keyPrefix,
scopes: created.scopes,
expiresAt: created.expiresAt?.toISOString() ?? null,
},
},
};
},
}),
);
app.delete(
'/api/api-keys/:id',
bodylessMutation(db, {
schema: revokeApiKeySchema,
permission: { authorize: requireApiKeyManagement },
invalidMessage: 'Invalid API key revocation.',
async mutate({ principal, params, tx, now }) {
const id = params.id;
if (!id) throw MutationError.notFound('API key');
// Ownership is part of the lookup so another user's real key and a
// random UUID are indistinguishable to non-admin callers.
const visibleKey = principal.isPlatformAdmin
? eq(apiKeys.id, id)
: and(eq(apiKeys.id, id), eq(apiKeys.userId, principal.userId));
const [record] = await tx.select().from(apiKeys).where(visibleKey).limit(1);
if (!record) throw MutationError.notFound('API key');
let revoked = record;
if (!record.revokedAt) {
const [updated] = await tx
.update(apiKeys)
.set({ revokedAt: now })
.where(and(eq(apiKeys.id, record.id), isNull(apiKeys.revokedAt)))
.returning();
if (updated) {
revoked = updated;
} else {
const [concurrentlyRevoked] = await tx
.select()
.from(apiKeys)
.where(eq(apiKeys.id, record.id))
.limit(1);
if (!concurrentlyRevoked) throw MutationError.notFound('API key');
revoked = concurrentlyRevoked;
}
}
return {
data: apiKeyMetadata(revoked),
activity: {
type: 'agent_action',
subject: `Revoked API key “${record.name}`,
meta: {
action: 'api_key.revoked',
apiKeyId: record.id,
targetUserId: record.userId,
keyPrefix: record.keyPrefix,
alreadyRevoked: record.revokedAt !== null,
},
},
};
},
}),
);
return app;
}
+143
View File
@@ -0,0 +1,143 @@
import { NOTIFICATION_KINDS } from '@pig/core';
import type { Database } from '@pig/db';
import { accounts, channelLinks, notificationOutbox } from '@pig/db';
import { and, desc, eq, inArray } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { requireCapability } from '../lib/auth';
import {
bodylessMutation,
MutationError,
mutation,
type ApiEnv,
type MutationDefinition,
} from '../lib/mutation';
import { normaliseBuzzRelayUrl } from '../services/buzz';
const buzzLinkSchema = z
.object({
channelId: z.string().uuid(),
channelName: z.string().trim().min(1).max(120).nullable().optional(),
accountId: z.string().uuid(),
notifyOn: z.array(z.enum(NOTIFICATION_KINDS)).min(1).default([...NOTIFICATION_KINDS]),
})
.strict();
const emptyMutationSchema = z.object({}).strict();
export function buzzWorkspaceId(relayUrl: string): string {
return new URL(normaliseBuzzRelayUrl(relayUrl)).host;
}
export function createBuzzLinkMutationDefinition(
workspaceId: string,
): MutationDefinition<typeof buzzLinkSchema, typeof channelLinks.$inferSelect> {
return {
schema: buzzLinkSchema,
permission: { capability: 'settings:admin' },
invalidMessage: 'Invalid Buzz channel link.',
async mutate({ input, principal, tx, now }) {
const [account] = await tx
.select({ id: accounts.id, name: accounts.name })
.from(accounts)
.where(eq(accounts.id, input.accountId))
.limit(1);
if (!account) throw MutationError.notFound('Account');
const [link] = await tx
.insert(channelLinks)
.values({
...input,
platform: 'buzz',
workspaceId,
linkedByUserId: principal.userId,
updatedAt: now,
})
.onConflictDoUpdate({
target: [channelLinks.platform, channelLinks.workspaceId, channelLinks.channelId],
set: {
accountId: input.accountId,
channelName: input.channelName,
notifyOn: input.notifyOn,
linkedByUserId: principal.userId,
updatedAt: now,
},
})
.returning();
if (!link) throw new MutationError('write_failed', 'Buzz channel was not linked.', 409);
return {
data: link,
activity: {
type: 'buzz',
subject: `Linked Buzz channel to ${account.name}`,
accountId: account.id,
meta: { action: 'linked', integration: 'buzz', channelId: link.channelId },
},
};
},
};
}
function createBuzzUnlinkMutationDefinition(): MutationDefinition<
typeof emptyMutationSchema,
{ deleted: true }
> {
return {
schema: emptyMutationSchema,
permission: { capability: 'settings:admin' },
invalidMessage: 'Invalid Buzz unlink request.',
async mutate({ params, tx }) {
if (!params.id) throw MutationError.notFound('Buzz channel link');
const [link] = await tx
.select()
.from(channelLinks)
.where(and(eq(channelLinks.id, params.id), eq(channelLinks.platform, 'buzz')))
.limit(1);
if (!link) throw MutationError.notFound('Buzz channel link');
await tx
.update(notificationOutbox)
.set({ status: 'cancelled', leasedBy: null, leasedUntil: null })
.where(
and(
eq(notificationOutbox.linkId, link.id),
inArray(notificationOutbox.status, ['pending', 'leased']),
),
);
await tx.delete(channelLinks).where(eq(channelLinks.id, link.id));
return {
data: { deleted: true as const },
activity: {
type: 'buzz',
subject: 'Unlinked Buzz channel',
accountId: link.accountId ?? undefined,
meta: { action: 'unlinked', integration: 'buzz', channelId: link.channelId },
},
};
},
};
}
export function createBuzzRoutes(db: Database, relayUrl: string): Hono<ApiEnv> {
const app = new Hono<ApiEnv>();
const workspaceId = buzzWorkspaceId(relayUrl);
app.get('/api/integrations/buzz/channel-links', async (c) => {
requireCapability(c.get('principal'), 'settings:admin');
const links = await db
.select({ link: channelLinks, accountName: accounts.name })
.from(channelLinks)
.innerJoin(accounts, eq(accounts.id, channelLinks.accountId))
.where(and(eq(channelLinks.platform, 'buzz'), eq(channelLinks.workspaceId, workspaceId)))
.orderBy(desc(channelLinks.updatedAt));
return c.json(links);
});
app.post(
'/api/integrations/buzz/channel-links',
mutation(db, createBuzzLinkMutationDefinition(workspaceId)),
);
app.delete(
'/api/integrations/buzz/channel-links/:id',
bodylessMutation(db, createBuzzUnlinkMutationDefinition()),
);
return app;
}
+269
View File
@@ -0,0 +1,269 @@
import {
CONSUMING_ALLOCATION_STATUSES,
GPU_SOCKETS,
GUARANTEE_TYPES,
INTERCONNECT_TYPES,
SECURITY_TIERS,
} from '@pig/core';
import type { Allocation, CapacityCommitment, Database } from '@pig/db';
import { Hono } from 'hono';
import { z } from 'zod';
import type { ApiEnv } from '../lib/mutation';
import type { MutationDefinition } from '../lib/mutation';
import { MutationError, mutation } from '../lib/mutation';
import {
CapacityWriteService,
type CapacityWriteTransaction,
} from '../services/capacity-writes';
const uuid = z.string().uuid();
const cents = z.number().int().nonnegative();
const percentage = z.number().min(0).max(100);
const gpuHours = z.number().positive().refine(
(value) => Math.abs(value * 100 - Math.round(value * 100)) < 1e-7,
'GPU-hours may have at most two decimal places.',
);
const currency = z.string().regex(/^[A-Z]{3}$/);
const shape = z
.object({
intervals: z.array(z.string().datetime()).min(2),
quantities: z.array(z.number().int().nonnegative()).min(1),
})
.strict();
const commitmentFields = {
accountId: uuid,
siteId: uuid.nullable().optional(),
supplyDealId: uuid.nullable().optional(),
name: z.string().trim().min(1).max(200),
gpuType: z.string().trim().min(1).max(100),
socket: z.enum(GPU_SOCKETS).nullable().optional(),
gpuCount: z.number().int().positive(),
interconnectType: z.enum(INTERCONNECT_TYPES).optional(),
securityTier: z.enum(SECURITY_TIERS).optional(),
startsAt: z.string().datetime(),
endsAt: z.string().datetime(),
totalGpuHours: gpuHours,
costPerGpuHourCents: cents,
currency: currency.optional(),
shape: shape.nullable().optional(),
colocateWith: z.array(uuid).max(100).optional(),
isContiguous: z.boolean().optional(),
minimumSpendCents: cents.nullable().optional(),
isAutoRenew: z.boolean().optional(),
noticeDays: z.number().int().nonnegative().nullable().optional(),
takeOrPayFloorPct: percentage.nullable().optional(),
prepaidPct: percentage.nullable().optional(),
prepaidAmountCents: cents.nullable().optional(),
usefulLifeYears: z.number().positive().max(100).nullable().optional(),
salvageValuePct: percentage.nullable().optional(),
depreciationStartAt: z.string().datetime().nullable().optional(),
costOfCapitalBps: z.number().int().nonnegative().nullable().optional(),
financingInstrument: z.string().trim().min(1).max(200).nullable().optional(),
oversubscriptionPct: z.number().min(0).max(1000).optional(),
notes: z.string().max(10_000).nullable().optional(),
};
const createCommitmentSchema = z.object(commitmentFields).strict();
const updateCommitmentSchema = z
.object({
...commitmentFields,
terminatedAt: z.string().datetime().nullable().optional(),
})
.partial()
.strict()
.refine((input) => Object.keys(input).length > 0, 'At least one change is required.');
const allocationFields = {
capacityCommitmentId: uuid,
demandDealId: uuid,
gpuHours,
pricePerGpuHourCents: cents,
currency: currency.optional(),
startsAt: z.string().datetime(),
endsAt: z.string().datetime(),
guaranteeType: z.enum(GUARANTEE_TYPES).optional(),
priority: z.number().int().nonnegative().optional(),
complianceDecisionId: uuid.nullable().optional(),
notes: z.string().max(10_000).nullable().optional(),
};
const createAllocationSchema = z
.object({
...allocationFields,
status: z.enum(CONSUMING_ALLOCATION_STATUSES),
})
.strict();
const createHoldSchema = z
.object({
...allocationFields,
pricePerGpuHourCents: cents.optional(),
holdExpiresAt: z.string().datetime(),
holdOpportunityCostCents: cents.nullable().optional(),
})
.strict();
const releaseSchema = z
.object({ reason: z.string().trim().min(1).max(1_000).optional() })
.strict();
type CapacityWriteOperations = Pick<
CapacityWriteService,
| 'createCommitment'
| 'updateCommitment'
| 'createAllocation'
| 'createHold'
| 'releaseAllocation'
>;
type ServiceFactory = (tx: CapacityWriteTransaction) => CapacityWriteOperations;
const service: ServiceFactory = (tx) => new CapacityWriteService(tx);
export function createCommitmentMutationDefinition(
makeService: ServiceFactory = service,
): MutationDefinition<typeof createCommitmentSchema, CapacityCommitment> {
return {
schema: createCommitmentSchema,
permission: { capability: 'commitment:write' as const, team: 'supply' as const },
invalidMessage: 'Invalid capacity commitment.',
async mutate({ input, tx, now }) {
const result = await makeService(tx).createCommitment(input, now);
return {
data: result.commitment,
activity: {
type: 'note' as const,
subject: `Created capacity commitment: ${result.commitment.name}`,
accountId: result.commitment.accountId,
supplyDealId: result.commitment.supplyDealId ?? undefined,
meta: { capacityCommitmentId: result.commitment.id },
},
};
},
};
}
export function updateCommitmentMutationDefinition(
makeService: ServiceFactory = service,
): MutationDefinition<typeof updateCommitmentSchema, CapacityCommitment> {
return {
schema: updateCommitmentSchema,
permission: { capability: 'commitment:write' as const, team: 'supply' as const },
invalidMessage: 'Invalid capacity commitment update.',
async mutate({ input, params, tx, now }) {
if (!params.id) throw MutationError.notFound('Capacity commitment');
const result = await makeService(tx).updateCommitment(params.id, input, now);
return {
data: result.commitment,
activity: {
type: 'note' as const,
subject: `Updated capacity commitment: ${result.commitment.name}`,
accountId: result.commitment.accountId,
supplyDealId: result.commitment.supplyDealId ?? undefined,
meta: {
capacityCommitmentId: result.commitment.id,
changedFields: Object.keys(input),
...(input.terminatedAt !== undefined
? { terminatedAt: result.commitment.terminatedAt?.toISOString() ?? null }
: {}),
},
},
};
},
};
}
export function createAllocationMutationDefinition(
makeService: ServiceFactory = service,
): MutationDefinition<typeof createAllocationSchema, Allocation> {
return {
schema: createAllocationSchema,
permission: { capability: 'deal:write' as const, team: 'demand' as const },
invalidMessage: 'Invalid allocation.',
async mutate({ input, principal, tx, now }) {
const result = await makeService(tx).createAllocation(input, principal, now);
return {
data: result.allocation,
activity: {
type: 'note' as const,
subject: `Allocated ${input.gpuHours} GPU-hours from ${result.commitment.name}`,
accountId: result.deal.accountId,
demandDealId: result.deal.id,
meta: {
allocationId: result.allocation.id,
capacityCommitmentId: result.commitment.id,
status: result.allocation.status,
pricePerGpuHourCents: result.allocation.pricePerGpuHourCents,
},
},
};
},
};
}
export function createHoldMutationDefinition(
makeService: ServiceFactory = service,
): MutationDefinition<typeof createHoldSchema, Allocation> {
return {
schema: createHoldSchema,
permission: { capability: 'deal:write' as const, team: 'demand' as const },
invalidMessage: 'Invalid capacity hold.',
async mutate({ input, principal, tx, now }) {
const result = await makeService(tx).createHold(input, principal, now);
return {
data: result.allocation,
activity: {
type: 'note' as const,
subject: `Held ${input.gpuHours} GPU-hours from ${result.commitment.name}`,
accountId: result.deal.accountId,
demandDealId: result.deal.id,
meta: {
allocationId: result.allocation.id,
capacityCommitmentId: result.commitment.id,
holdExpiresAt: input.holdExpiresAt,
},
},
};
},
};
}
export function releaseAllocationMutationDefinition(
makeService: ServiceFactory = service,
): MutationDefinition<typeof releaseSchema, Allocation> {
return {
schema: releaseSchema,
permission: { capability: 'deal:write' as const, team: 'demand' as const },
invalidMessage: 'Invalid allocation release.',
async mutate({ input, params, tx, now }) {
if (!params.id) throw MutationError.notFound('Allocation');
const result = await makeService(tx).releaseAllocation(params.id, input, now);
return {
data: result.allocation,
activity: {
type: 'note' as const,
subject: `Released allocation ${result.allocation.id}`,
accountId: result.accountId,
demandDealId: result.allocation.demandDealId ?? undefined,
meta: {
allocationId: result.allocation.id,
capacityCommitmentId: result.allocation.capacityCommitmentId,
from: result.previousStatus,
to: 'released',
reason: input.reason,
},
},
};
},
};
}
export function createCapacityWriteRoutes(db: Database): Hono<ApiEnv> {
const routes = new Hono<ApiEnv>();
routes.post('/api/commitments', mutation(db, createCommitmentMutationDefinition()));
routes.patch('/api/commitments/:id', mutation(db, updateCommitmentMutationDefinition()));
routes.post('/api/allocations', mutation(db, createAllocationMutationDefinition()));
routes.post('/api/allocations/holds', mutation(db, createHoldMutationDefinition()));
routes.post(
'/api/allocations/:id/release',
mutation(db, releaseAllocationMutationDefinition()),
);
return routes;
}
+541
View File
@@ -0,0 +1,541 @@
import { and, eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import {
ACCOUNT_SIDES,
CONTRACT_STATUSES,
CONTRACT_TYPES,
SLA_KINDS,
SLA_METRICS,
} from '@pig/core';
import type { AccountSide } from '@pig/core';
import type { Contract, Database } from '@pig/db';
import {
accounts,
capacityCommitments,
contractObligations,
contracts,
demandDeals,
slaMetricTargets,
slaTerms,
supplyDeals,
} from '@pig/db';
import { effectivePermissions, requireCapability, type Principal } from '../lib/auth';
import {
MutationError,
mutation,
type ApiEnv,
type MutationDefinition,
} from '../lib/mutation';
import {
ContractService,
validateParentRelationship,
} from '../services/contracts';
import { permissionGranted } from '@pig/core';
const dateValue = z.string().datetime().nullable();
const percentage = z.number().min(0).max(100).nullable();
const nullableId = z.string().uuid().nullable();
const maintenanceClassSchema = z.object({
class: z.string().min(1).max(80),
noticeValue: z.number().int().nonnegative(),
noticeUnit: z.string().min(1).max(40),
allowancePerPeriodHours: z.number().nonnegative().optional(),
excludedFromUptime: z.boolean(),
});
const slaInputSchema = z.object({
kind: z.enum(SLA_KINDS),
uptimeTargetPct: z.number().min(0).max(100).nullable().optional(),
nodeReplacementHours: z.number().int().nonnegative().nullable().optional(),
mttrHours: z.number().int().nonnegative().nullable().optional(),
supportResponseHours: z.number().int().nonnegative().nullable().optional(),
measurementWindow: z.string().min(1).max(80).optional(),
measurementUnit: z.string().min(1).max(80).optional(),
remedyType: z.enum(['service_credit', 'fee_abatement', 'termination_right']).optional(),
abatementTriggerValue: z.number().int().positive().nullable().optional(),
abatementTriggerUnit: z.string().min(1).max(40).nullable().optional(),
claimDeadlineValue: z.number().int().positive().nullable().optional(),
claimDeadlineUnit: z.string().min(1).max(40).optional(),
creditExpiryMonths: z.number().int().positive().nullable().optional(),
isSoleRemedy: z.boolean().optional(),
sparePoolObligation: z.string().max(4000).nullable().optional(),
sparePoolScope: z.array(z.string().min(1).max(120)).max(30).optional(),
maintenanceClasses: z.array(maintenanceClassSchema).max(20).optional(),
reasonableEndeavoursDaysPerYear: z.number().int().min(0).max(366).nullable().optional(),
rcaDeliveryHours: z.number().int().positive().nullable().optional(),
creditSchedule: z
.array(
z.object({
belowPct: z.number().min(0).max(100),
creditPct: z.number().min(0).max(100),
}),
)
.max(20)
.optional(),
creditCapPct: z.number().min(0).max(100).nullable().optional(),
exclusions: z.string().max(8000).nullable().optional(),
metricTargets: z
.array(
z.object({
metric: z.enum(SLA_METRICS),
targetValue: z.number(),
unit: z.string().max(80).nullable().optional(),
}),
)
.max(20)
.optional(),
});
const contractFields = {
type: z.enum(CONTRACT_TYPES).optional(),
status: z.enum(CONTRACT_STATUSES).optional(),
side: z.enum(ACCOUNT_SIDES).optional(),
title: z.string().min(1).max(240).optional(),
externalReference: z.string().max(240).nullable().optional(),
demandDealId: nullableId.optional(),
supplyDealId: nullableId.optional(),
capacityCommitmentId: nullableId.optional(),
parentContractId: nullableId.optional(),
contractingPartyName: z.string().max(240).nullable().optional(),
takeOrPayFloorPct: percentage.optional(),
prepaidPct: percentage.optional(),
terminationTier: z
.enum(['1_prepaid', '2_take_or_pay', '3_cancellable'])
.nullable()
.optional(),
assignableOnDefault: z.boolean().optional(),
assignmentDeadlineBusinessDays: z.number().int().positive().nullable().optional(),
effectiveAt: dateValue.optional(),
expiresAt: dateValue.optional(),
executedAt: dateValue.optional(),
terminatedAt: dateValue.optional(),
isAutoRenew: z.boolean().optional(),
noticeDays: z.number().int().positive().nullable().optional(),
valueCents: z.number().int().nonnegative().nullable().optional(),
currency: z.string().length(3).optional(),
governingLaw: z.string().max(240).nullable().optional(),
documentUrl: z.string().url().max(2000).nullable().optional(),
ownerUserId: nullableId.optional(),
notes: z.string().max(12000).nullable().optional(),
sla: slaInputSchema.nullable().optional(),
};
const updateContractSchema = z.object(contractFields);
const createContractSchema = updateContractSchema.extend({
accountId: z.string().uuid(),
type: z.enum(CONTRACT_TYPES),
side: z.enum(ACCOUNT_SIDES),
title: z.string().min(1).max(240),
});
const obligationFields = {
title: z.string().min(1).max(240).optional(),
description: z.string().max(8000).nullable().optional(),
kind: z.enum(['renewal_notice', 'milestone', 'payment', 'review', 'true_up']).optional(),
dueAt: z.string().datetime().optional(),
completedAt: dateValue.optional(),
ownerUserId: nullableId.optional(),
};
const createObligationSchema = z.object({
...obligationFields,
title: z.string().min(1).max(240),
dueAt: z.string().datetime(),
});
const updateObligationSchema = z.object(obligationFields);
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0];
function writtenRow<Row>(row: Row | undefined, resource: string): Row {
if (row === undefined) {
throw new Error(`${resource} write completed without returning a row.`);
}
return row;
}
function requiredRouteParam(
params: Readonly<Record<string, string>>,
name: 'id' | 'obligationId',
): string {
const value = params[name];
if (!value) {
throw new MutationError(
'invalid_route_parameter',
`Route parameter '${name}' is required.`,
400,
);
}
return value;
}
function requireAnyContractPermission(principal: Principal): void {
const grants = effectivePermissions(principal);
if (
permissionGranted(grants, 'contract:sign', 'supply') ||
permissionGranted(grants, 'contract:sign', 'demand')
) {
return;
}
requireCapability(principal, 'contract:sign', 'demand');
}
function ensureSidePermission(principal: Principal, side: AccountSide): void {
if (side === 'both') {
throw new MutationError(
'invalid_contract_side',
'A contract must govern either the supply or demand side.',
400,
);
}
requireCapability(principal, 'contract:sign', side);
}
function supportsSide(accountSide: AccountSide, contractSide: AccountSide): boolean {
return accountSide === 'both' || accountSide === contractSide;
}
async function requireRelationships(
tx: Transaction,
record: Pick<
Contract,
| 'id'
| 'accountId'
| 'side'
| 'parentContractId'
| 'demandDealId'
| 'supplyDealId'
| 'capacityCommitmentId'
>,
): Promise<void> {
const [account] = await tx.select().from(accounts).where(eq(accounts.id, record.accountId)).limit(1);
if (!account) throw MutationError.notFound('Account');
if (!supportsSide(account.side, record.side as AccountSide)) {
throw new MutationError(
'side_mismatch',
`This account is not recorded on the ${record.side} side.`,
409,
);
}
if (record.parentContractId) {
const [parent] = await tx
.select()
.from(contracts)
.where(eq(contracts.id, record.parentContractId))
.limit(1);
if (!parent) throw MutationError.notFound('Parent contract');
const ancestors: Contract[] = [];
let cursor = parent;
while (cursor.parentContractId) {
const [next] = await tx
.select()
.from(contracts)
.where(eq(contracts.id, cursor.parentContractId))
.limit(1);
if (!next) throw MutationError.notFound('Parent contract');
ancestors.push(next);
if (ancestors.length > 100) {
throw new MutationError('invalid_hierarchy', 'Contract hierarchy is too deep.', 409);
}
cursor = next;
}
const issue = validateParentRelationship(record, parent, ancestors);
if (issue) throw new MutationError('invalid_hierarchy', issue, 409);
}
if (record.demandDealId) {
const [deal] = await tx
.select()
.from(demandDeals)
.where(eq(demandDeals.id, record.demandDealId))
.limit(1);
if (!deal) throw MutationError.notFound('Demand deal');
if (record.side !== 'demand' || deal.accountId !== record.accountId) {
throw new MutationError('relationship_mismatch', 'Demand deal does not match this contract.', 409);
}
}
if (record.supplyDealId) {
const [deal] = await tx
.select()
.from(supplyDeals)
.where(eq(supplyDeals.id, record.supplyDealId))
.limit(1);
if (!deal) throw MutationError.notFound('Supply deal');
if (record.side !== 'supply' || deal.accountId !== record.accountId) {
throw new MutationError('relationship_mismatch', 'Supply deal does not match this contract.', 409);
}
}
if (record.capacityCommitmentId) {
const [commitment] = await tx
.select()
.from(capacityCommitments)
.where(eq(capacityCommitments.id, record.capacityCommitmentId))
.limit(1);
if (!commitment) throw MutationError.notFound('Capacity commitment');
if (record.side !== 'supply' || commitment.accountId !== record.accountId) {
throw new MutationError(
'relationship_mismatch',
'Capacity commitment does not match this contract.',
409,
);
}
}
}
function date(value: string | null | undefined): Date | null | undefined {
return value === undefined ? undefined : value === null ? null : new Date(value);
}
function contractValues(input: z.infer<typeof updateContractSchema>) {
const { sla: _sla, ...plain } = input;
return {
...plain,
takeOrPayFloorPct:
plain.takeOrPayFloorPct === undefined || plain.takeOrPayFloorPct === null
? plain.takeOrPayFloorPct
: String(plain.takeOrPayFloorPct),
prepaidPct:
plain.prepaidPct === undefined || plain.prepaidPct === null
? plain.prepaidPct
: String(plain.prepaidPct),
effectiveAt: date(plain.effectiveAt),
expiresAt: date(plain.expiresAt),
executedAt: date(plain.executedAt),
terminatedAt: date(plain.terminatedAt),
};
}
async function writeSla(
tx: Transaction,
contractId: string,
input: z.infer<typeof slaInputSchema> | null | undefined,
now: Date,
): Promise<void> {
if (input === undefined) return;
const existing = await tx.select().from(slaTerms).where(eq(slaTerms.contractId, contractId));
if (input === null) {
if (existing.length) await tx.delete(slaTerms).where(eq(slaTerms.contractId, contractId));
return;
}
const { metricTargets, ...values } = input;
const stored = {
...values,
uptimeTargetPct:
values.uptimeTargetPct == null ? values.uptimeTargetPct : String(values.uptimeTargetPct),
creditCapPct:
values.creditCapPct == null ? values.creditCapPct : String(values.creditCapPct),
updatedAt: now,
};
const existingTerm = existing[0];
const writtenTerms = existingTerm
? await tx.update(slaTerms).set(stored).where(eq(slaTerms.id, existingTerm.id)).returning()
: await tx.insert(slaTerms).values({ contractId, ...stored }).returning();
const term = writtenRow(writtenTerms[0], 'SLA term');
if (metricTargets !== undefined) {
await tx.delete(slaMetricTargets).where(eq(slaMetricTargets.slaTermId, term.id));
if (metricTargets.length) {
await tx.insert(slaMetricTargets).values(
metricTargets.map((target) => ({
slaTermId: term.id,
metric: target.metric,
targetValue: String(target.targetValue),
unit: target.unit,
})),
);
}
}
}
export function createContractMutationDefinition(): MutationDefinition<
typeof createContractSchema,
Contract
> {
return {
schema: createContractSchema,
permission: { authorize: requireAnyContractPermission },
invalidMessage: 'Invalid contract.',
async mutate({ input, principal, tx, now }) {
ensureSidePermission(principal, input.side);
const { sla, accountId, type, side, title, ...fields } = input;
const pending = {
id: crypto.randomUUID(),
accountId,
type,
side,
title,
...contractValues(fields),
} as Contract;
await requireRelationships(tx, pending);
const createdRows = await tx
.insert(contracts)
.values({ accountId, type, side, title, ...contractValues(fields), updatedAt: now })
.returning();
const created = writtenRow(createdRows[0], 'Contract');
await writeSla(tx, created.id, sla, now);
return {
data: created,
activity: {
type: 'contract_event',
subject: `Created ${created.title}`,
accountId: created.accountId,
meta: { contractId: created.id, status: created.status, contractType: created.type },
},
};
},
};
}
export function updateContractMutationDefinition(): MutationDefinition<
typeof updateContractSchema,
Contract
> {
return {
schema: updateContractSchema,
permission: { authorize: requireAnyContractPermission },
invalidMessage: 'Invalid contract update.',
async mutate({ input, principal, params, tx, now }) {
const contractId = requiredRouteParam(params, 'id');
const [before] = await tx.select().from(contracts).where(eq(contracts.id, contractId)).limit(1);
if (!before) throw MutationError.notFound('Contract');
const { sla, ...fields } = input;
const pending = { ...before, ...contractValues(fields), updatedAt: now };
ensureSidePermission(principal, pending.side as AccountSide);
await requireRelationships(tx, pending);
const updatedRows = await tx
.update(contracts)
.set({ ...contractValues(fields), updatedAt: now })
.where(eq(contracts.id, before.id))
.returning();
const updated = writtenRow(updatedRows[0], 'Contract');
await writeSla(tx, updated.id, sla, now);
return {
data: updated,
activity: {
type: 'contract_event',
subject: `Updated ${updated.title}`,
accountId: updated.accountId,
meta: {
contractId: updated.id,
fromStatus: before.status,
toStatus: updated.status,
},
},
};
},
};
}
export function createObligationMutationDefinition(): MutationDefinition<
typeof createObligationSchema,
typeof contractObligations.$inferSelect
> {
return {
schema: createObligationSchema,
permission: { authorize: requireAnyContractPermission },
invalidMessage: 'Invalid contract obligation.',
async mutate({ input, principal, params, tx, now }) {
const contractId = requiredRouteParam(params, 'id');
const [contract] = await tx.select().from(contracts).where(eq(contracts.id, contractId)).limit(1);
if (!contract) throw MutationError.notFound('Contract');
ensureSidePermission(principal, contract.side as AccountSide);
const createdRows = await tx
.insert(contractObligations)
.values({
...input,
contractId: contract.id,
dueAt: new Date(input.dueAt),
completedAt: date(input.completedAt),
updatedAt: now,
})
.returning();
const created = writtenRow(createdRows[0], 'Contract obligation');
return {
data: created,
activity: {
type: 'task',
subject: `Added obligation: ${created.title}`,
accountId: contract.accountId,
meta: { contractId: contract.id, obligationId: created.id, dueAt: created.dueAt },
},
};
},
};
}
export function updateObligationMutationDefinition(): MutationDefinition<
typeof updateObligationSchema,
typeof contractObligations.$inferSelect
> {
return {
schema: updateObligationSchema,
permission: { authorize: requireAnyContractPermission },
invalidMessage: 'Invalid contract obligation update.',
async mutate({ input, principal, params, tx, now }) {
const contractId = requiredRouteParam(params, 'id');
const obligationId = requiredRouteParam(params, 'obligationId');
const [before] = await tx
.select({ obligation: contractObligations, contract: contracts })
.from(contractObligations)
.innerJoin(contracts, eq(contracts.id, contractObligations.contractId))
.where(
and(
eq(contractObligations.id, obligationId),
eq(contractObligations.contractId, contractId),
),
)
.limit(1);
if (!before) throw MutationError.notFound('Contract obligation');
ensureSidePermission(principal, before.contract.side as AccountSide);
const updatedRows = await tx
.update(contractObligations)
.set({
...input,
dueAt: input.dueAt ? new Date(input.dueAt) : undefined,
completedAt: date(input.completedAt),
updatedAt: now,
})
.where(eq(contractObligations.id, before.obligation.id))
.returning();
const updated = writtenRow(updatedRows[0], 'Contract obligation');
return {
data: updated,
activity: {
type: 'task',
subject: `${updated.completedAt ? 'Completed' : 'Updated'} obligation: ${updated.title}`,
accountId: before.contract.accountId,
meta: {
contractId: before.contract.id,
obligationId: updated.id,
completedAt: updated.completedAt,
},
},
};
},
};
}
export function createContractRoutes(db: Database) {
const routes = new Hono<ApiEnv>();
const service = new ContractService(db);
routes.get('/api/contracts', async (c) => c.json(await service.list()));
routes.get('/api/contracts/:id', async (c) => {
const detail = await service.detail(c.req.param('id'));
return detail ? c.json(detail) : c.json({ error: 'Contract not found.' }, 404);
});
routes.post('/api/contracts', mutation(db, createContractMutationDefinition()));
routes.patch('/api/contracts/:id', mutation(db, updateContractMutationDefinition()));
routes.post(
'/api/contracts/:id/obligations',
mutation(db, createObligationMutationDefinition()),
);
routes.patch(
'/api/contracts/:id/obligations/:obligationId',
mutation(db, updateObligationMutationDefinition()),
);
return routes;
}
+60
View File
@@ -0,0 +1,60 @@
import { DEMAND_STAGES } from '@pig/core';
import type { Database } from '@pig/db';
import { demandDeals } from '@pig/db';
import { eq } from 'drizzle-orm';
import { z } from 'zod';
import { MutationError, mutation } from '../lib/mutation';
import type { NotificationOutbox } from '../services/notification-outbox';
const demandStageMutationSchema = z
.object({ stage: z.enum(DEMAND_STAGES) })
.strict();
/**
* Stage transitions and their audit evidence commit together, so a pipeline
* review can never see a new stage without who changed it and when.
*/
export function createDemandStageMutation(db: Database, notifications?: NotificationOutbox) {
return mutation(db, {
schema: demandStageMutationSchema,
permission: { capability: 'deal:write', team: 'demand' },
invalidMessage: 'Invalid demand stage transition.',
async mutate({ input, params, tx, now }) {
const id = params.id;
if (!id) throw MutationError.notFound('Demand deal');
const [before] = await tx.select().from(demandDeals).where(eq(demandDeals.id, id)).limit(1);
if (!before) throw MutationError.notFound('Demand deal');
const [updated] = await tx
.update(demandDeals)
.set({
stage: input.stage,
stageChangedAt: now,
updatedAt: now,
lastActivityAt: now,
})
.where(eq(demandDeals.id, id))
.returning();
if (!updated) throw MutationError.notFound('Demand deal');
await notifications?.enqueueStageChange(tx, {
accountId: before.accountId,
dealId: before.id,
dealSide: 'demand',
dealName: before.name,
fromStage: before.stage,
toStage: input.stage,
changedAt: now.toISOString(),
});
return {
data: updated,
activity: {
type: 'stage_change',
subject: `${before.stage}${input.stage}`,
accountId: before.accountId,
demandDealId: before.id,
meta: { from: before.stage, to: input.stage },
},
};
},
});
}
+143
View File
@@ -0,0 +1,143 @@
import { FACT_STATUSES } from '@pig/core';
import type { Fact, Database } from '@pig/db';
import { accounts, contacts, facts } from '@pig/db';
import { and, desc, eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import {
apiError,
MutationError,
mutation,
type ApiEnv,
type MutationDefinition,
} from '../lib/mutation';
const factDecisionSchema = z
.object({
status: z.enum(FACT_STATUSES).refine(
(status): status is 'approved' | 'dismissed' =>
status === 'approved' || status === 'dismissed',
'A review may only approve or dismiss a proposed fact.',
),
})
.strict();
export interface FactDecisionResult {
fact: Fact;
/** Approval records a judgement; applying arbitrary field names is a separate safe system. */
recordUpdated: false;
}
function carriesEvidence(fact: Fact): boolean {
return Boolean(
fact.sourceUrl?.trim() ||
(fact.evidence && Object.keys(fact.evidence).length > 0),
);
}
/**
* Human approval accepts the evidence, not an untyped write to an arbitrary
* CRM field. Keeping those operations separate prevents a plausible claim
* such as `email` from silently overwriting a real person's record.
*/
export const factDecisionDefinition: MutationDefinition<
typeof factDecisionSchema,
FactDecisionResult
> = {
schema: factDecisionSchema,
permission: { capability: 'data:import', team: 'research' },
invalidMessage: 'Invalid fact review decision.',
async mutate({ input, params, principal, tx, now }) {
const id = params.id;
if (!id) throw MutationError.notFound('Fact');
const [before] = await tx.select().from(facts).where(eq(facts.id, id)).limit(1);
if (!before) throw MutationError.notFound('Fact');
if (before.status !== 'proposed') {
throw new MutationError(
'fact_already_decided',
'Only a proposed fact can be reviewed.',
409,
);
}
if (input.status === 'approved' && !carriesEvidence(before)) {
throw new MutationError(
'missing_evidence',
'A fact needs a source or evidence before it can be approved.',
409,
);
}
const [updated] = await tx
.update(facts)
.set({
status: input.status,
decidedByUserId: principal.userId,
decidedAt: now,
})
.where(and(eq(facts.id, id), eq(facts.status, 'proposed')))
.returning();
if (!updated) {
throw new MutationError(
'fact_already_decided',
'This fact was reviewed by someone else.',
409,
);
}
const approved = input.status === 'approved';
return {
data: { fact: updated, recordUpdated: false },
activity: {
type: 'agent_action',
subject: `${approved ? 'Approved' : 'Dismissed'} proposed ${before.field}`,
body: approved
? 'Evidence approved; the CRM record remains unchanged until field-aware application is available.'
: 'Proposal dismissed; the CRM record was not changed.',
accountId: before.accountId ?? undefined,
contactId: before.contactId ?? undefined,
meta: {
factId: before.id,
decision: input.status,
field: before.field,
recordUpdated: false,
},
},
};
},
};
export function createFactsRoute(db: Database) {
const route = new Hono<ApiEnv>();
route.get('/api/facts', async (c) => {
const parsedStatus = z
.enum(FACT_STATUSES)
.safeParse(c.req.query('status') ?? 'proposed');
if (!parsedStatus.success) {
return c.json(
apiError('invalid_request', 'Unknown fact status.', parsedStatus.error.issues),
400,
);
}
const rows = await db
.select({
fact: facts,
accountName: accounts.name,
contactName: contacts.fullName,
})
.from(facts)
.leftJoin(accounts, eq(facts.accountId, accounts.id))
.leftJoin(contacts, eq(facts.contactId, contacts.id))
.where(eq(facts.status, parsedStatus.data))
.orderBy(desc(facts.observedAt))
.limit(200);
return c.json({ facts: rows });
});
route.patch('/api/facts/:id/decision', mutation(db, factDecisionDefinition));
return route;
}
+130
View File
@@ -0,0 +1,130 @@
import type { Database } from '@pig/db';
import { Hono } from 'hono';
import { z } from 'zod';
import { requireCapability } from '../lib/auth';
import type { ApiEnv } from '../lib/mutation';
import { MutationError } from '../lib/mutation';
import {
GoogleApiError,
GoogleSheetsService,
type GoogleSheetsConfig,
} from '../services/google-sheets';
const identifier = z.string().trim().min(1).max(255).regex(/^[A-Za-z0-9_-]+$/);
const listSchema = z.object({
pageToken: z.string().min(1).max(2_048).optional(),
search: z.string().trim().max(100).optional(),
});
const tableSchema = z.object({
spreadsheetId: identifier,
sheetId: z.number().int().nonnegative(),
range: z.string().trim().min(1).max(40),
}).strict();
const GOOGLE_OAUTH_BINDING_COOKIE = 'pig_google_oauth';
export function createGoogleSheetsRoutes(
db: Database,
config: GoogleSheetsConfig,
fetchImpl: typeof fetch = fetch,
): Hono<ApiEnv> {
const routes = new Hono<ApiEnv>();
const service = new GoogleSheetsService(db, config, fetchImpl);
routes.get('/oauth/google/callback', async (context) => {
const state = context.req.query('state');
const code = context.req.query('code');
const denied = context.req.query('error');
const browserBinding = cookieValue(
context.req.header('cookie'),
oauthBindingCookieName(config.publicUrl),
);
context.header('Set-Cookie', oauthBindingCookie('', config.publicUrl, 0));
if (denied) return context.redirect(`${config.publicUrl}/imports?google=denied`);
if (!state || !code || !browserBinding) return context.text('Invalid Google authorization response.', 400);
try {
await service.completeOAuth({ state, code, browserBinding });
return context.redirect(`${config.publicUrl}/imports?google=connected`);
} catch {
// OAuth codes, state, token bodies, and provider diagnostics never enter a redirect or log.
return context.redirect(`${config.publicUrl}/imports?google=failed`);
}
});
routes.use('/api/imports/google/*', async (context, next) => {
requireCapability(context.get('principal'), 'data:import');
await next();
});
routes.get('/api/imports/google/status', async (context) =>
context.json(await service.connectionMetadata(context.get('principal').userId)));
routes.post('/api/imports/google/connect', async (context) => {
const started = await service.beginOAuth(context.get('principal'));
context.header('Set-Cookie', oauthBindingCookie(started.browserBinding, config.publicUrl, 600));
return context.json({ authorizationUrl: started.authorizationUrl });
});
routes.delete('/api/imports/google/connection', async (context) => {
await service.disconnect(context.get('principal'));
return context.body(null, 204);
});
routes.get('/api/imports/google/files', async (context) => {
const parsed = listSchema.safeParse({
pageToken: context.req.query('pageToken'),
search: context.req.query('search'),
});
if (!parsed.success) throw new MutationError('invalid_google_page', 'Invalid Google Drive page request.', 400);
try {
return context.json(await service.listSpreadsheets(context.get('principal').userId, parsed.data));
} catch (error) {
throw publicGoogleError(error);
}
});
routes.get('/api/imports/google/spreadsheets/:id/sheets', async (context) => {
const spreadsheetId = identifier.safeParse(context.req.param('id'));
if (!spreadsheetId.success) throw new MutationError('invalid_google_spreadsheet', 'Invalid spreadsheet ID.', 400);
try {
return context.json(await service.spreadsheetMetadata(context.get('principal').userId, spreadsheetId.data));
} catch (error) {
throw publicGoogleError(error);
}
});
routes.post('/api/imports/google/table', async (context) => {
const parsed = tableSchema.safeParse(await context.req.json());
if (!parsed.success) throw new MutationError('invalid_google_table', 'Invalid Google Sheets selection.', 400);
try {
return context.json(await service.readTable(context.get('principal').userId, parsed.data));
} catch (error) {
throw publicGoogleError(error);
}
});
return routes;
}
function oauthBindingCookieName(publicUrl: string): string {
return new URL(publicUrl).protocol === 'https:'
? `__Host-${GOOGLE_OAUTH_BINDING_COOKIE}`
: GOOGLE_OAUTH_BINDING_COOKIE;
}
function oauthBindingCookie(value: string, publicUrl: string, maxAge: number): string {
const secure = new URL(publicUrl).protocol === 'https:' ? '; Secure' : '';
return `${oauthBindingCookieName(publicUrl)}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`;
}
function cookieValue(header: string | undefined, name: string): string | null {
for (const part of header?.split(';') ?? []) {
const [key, ...rest] = part.trim().split('=');
if (key === name) return decodeURIComponent(rest.join('='));
}
return null;
}
function publicGoogleError(error: unknown): MutationError {
if (error instanceof MutationError) return error;
if (error instanceof GoogleApiError) {
return new MutationError(
error.reconnectRequired ? 'google_reconnect_required' : 'google_api_error',
error.message,
error.status >= 500 ? 502 : error.status === 404 ? 404 : 409,
);
}
return new MutationError('google_api_error', 'Google Sheets could not complete the request.', 502);
}
+125
View File
@@ -0,0 +1,125 @@
import { IMPORT_ENTITIES, IMPORT_ENTITY_DEFINITIONS } from '@pig/core';
import type { Database } from '@pig/db';
import { Hono } from 'hono';
import { z } from 'zod';
import { requireCapability } from '../lib/auth';
import type { ApiEnv, MutationDefinition } from '../lib/mutation';
import { MutationError, mutation } from '../lib/mutation';
import {
ImportService,
type ImportCommitResult,
type ImportTransaction,
} from '../services/imports';
import {
MAX_IMPORT_CELL_CHARS,
MAX_IMPORT_COLUMNS,
MAX_IMPORT_FILE_BYTES,
MAX_IMPORT_ROWS,
parseTabularFile,
} from '../services/tabular-import';
const planSchema = z.object({
entity: z.enum(IMPORT_ENTITIES),
sourceName: z.string().trim().min(1).max(255),
headers: z.array(z.string().min(1).max(255)).min(1).max(MAX_IMPORT_COLUMNS),
rows: z.array(
z.array(z.string().max(MAX_IMPORT_CELL_CHARS)).max(MAX_IMPORT_COLUMNS),
).min(1).max(MAX_IMPORT_ROWS),
mapping: z.record(z.string().min(1).max(255)),
keySourceColumn: z.string().min(1).max(255),
}).strict();
const commitSchema = planSchema.extend({
previewDigest: z.string().regex(/^[0-9a-f]{64}$/),
}).strict();
const parseSchema = z.object({
fileName: z.string().trim().min(1).max(255),
mimeType: z.string().max(255).optional(),
base64: z.string().min(1).max(Math.ceil(MAX_IMPORT_FILE_BYTES * 4 / 3) + 16),
}).strict();
interface ImportCommitOperations {
commit(
input: z.infer<typeof commitSchema>,
principal: Parameters<MutationDefinition<typeof commitSchema, ImportCommitResult>['mutate']>[0]['principal'],
now: Date,
): Promise<ImportCommitResult>;
}
type ServiceFactory = (tx: ImportTransaction) => ImportCommitOperations;
const service: ServiceFactory = (tx) => new ImportService(tx);
export function createImportCommitMutationDefinition(
makeService: ServiceFactory = service,
): MutationDefinition<typeof commitSchema, ImportCommitResult> {
return {
schema: commitSchema,
permission: { authorize: (principal) => requireCapability(principal, 'data:import') },
invalidMessage: 'Invalid import commit.',
async mutate({ input, principal, tx, now }) {
const result = await makeService(tx).commit(input, principal, now);
const entityLabel = IMPORT_ENTITY_DEFINITIONS[input.entity].label.toLocaleLowerCase();
return {
data: result,
activity: {
type: 'note',
subject: `Imported ${result.total} ${entityLabel}`,
meta: {
action: 'import',
entity: input.entity,
sourceName: input.sourceName,
keySourceColumn: input.keySourceColumn,
created: result.created,
updated: result.updated,
},
},
};
},
};
}
export function createImportRoutes(db: Database): Hono<ApiEnv> {
const routes = new Hono<ApiEnv>();
routes.use('/api/imports/*', async (context, next) => {
requireCapability(context.get('principal'), 'data:import');
await next();
});
routes.get('/api/imports/config', (context) => context.json({
entities: IMPORT_ENTITIES,
definitions: IMPORT_ENTITY_DEFINITIONS,
limits: {
fileBytes: MAX_IMPORT_FILE_BYTES,
rows: MAX_IMPORT_ROWS,
columns: MAX_IMPORT_COLUMNS,
cellCharacters: MAX_IMPORT_CELL_CHARS,
},
}));
routes.post('/api/imports/parse', async (context) => {
const parsed = parseSchema.safeParse(await context.req.json());
if (!parsed.success) throw new MutationError('invalid_import_file', 'Invalid import file upload.', 400, parsed.error.issues);
let bytes: Uint8Array;
try {
bytes = Buffer.from(parsed.data.base64, 'base64');
} catch {
throw new MutationError('invalid_import_file', 'The import file encoding is invalid.', 400);
}
try {
return context.json(parseTabularFile({
fileName: parsed.data.fileName,
mimeType: parsed.data.mimeType,
bytes,
}));
} catch (error) {
throw new MutationError(
'invalid_import_file',
error instanceof Error ? error.message : 'The import file could not be parsed.',
400,
);
}
});
routes.post('/api/imports/preview', async (context) => {
const parsed = planSchema.safeParse(await context.req.json());
if (!parsed.success) throw new MutationError('invalid_import', 'Invalid import plan.', 400, parsed.error.issues);
return context.json(await new ImportService(db).preview(parsed.data));
});
routes.post('/api/imports/commit', mutation(db, createImportCommitMutationDefinition()));
return routes;
}
@@ -0,0 +1,55 @@
import { Hono } from 'hono';
import type { Config } from '../lib/config';
import { requireCapability } from '../lib/auth';
import type { ApiEnv } from '../lib/mutation';
import { normaliseBuzzRelayUrl } from '../services/buzz';
export interface IntegrationReadiness {
slack: {
source: 'environment';
configured: boolean;
deliveryReady: boolean;
commandsReady: boolean;
};
buzz: {
source: 'environment';
configured: boolean;
deliveryReady: boolean;
relayUrl: string | null;
workspaceId: string | null;
};
}
export function integrationReadiness(config: Config): IntegrationReadiness {
const buzzRelayUrl = config.BUZZ_RELAY_URL
? normaliseBuzzRelayUrl(config.BUZZ_RELAY_URL)
: null;
const slackDeliveryReady = Boolean(config.SLACK_BOT_TOKEN);
const slackCommandsReady = Boolean(config.SLACK_SIGNING_SECRET);
const buzzReady = Boolean(buzzRelayUrl && config.BUZZ_PRIVATE_KEY);
return {
slack: {
source: 'environment',
configured: slackDeliveryReady && slackCommandsReady,
deliveryReady: slackDeliveryReady,
commandsReady: slackCommandsReady,
},
buzz: {
source: 'environment',
configured: buzzReady,
deliveryReady: buzzReady,
relayUrl: buzzRelayUrl,
workspaceId: buzzRelayUrl ? new URL(buzzRelayUrl).host : null,
},
};
}
export function createIntegrationSettingsRoutes(config: Config): Hono<ApiEnv> {
const app = new Hono<ApiEnv>();
app.get('/api/admin/integrations', (c) => {
requireCapability(c.get('principal'), 'settings:admin');
return c.json(integrationReadiness(config));
});
return app;
}
+229
View File
@@ -0,0 +1,229 @@
import { lt, and, eq } from 'drizzle-orm';
import { notionConnections, notionOauthStates, type Database } from '@pig/db';
import { deleteCookie, getCookie, setCookie } from 'hono/cookie';
import { Hono } from 'hono';
import { z } from 'zod';
import type { Config } from '../lib/config';
import { requireCapability } from '../lib/auth';
import type { ApiEnv } from '../lib/mutation';
import { decryptSecret, encryptSecret, encryptionReady } from '../lib/secrets';
import {
createNotionOAuthAttempt,
materializeNotionDataSource,
NotionApiError,
NotionClient,
notionAuthorizationUrl,
notionConnectionMetadata,
type NotionCredentials,
verifyNotionOAuthAttempt,
hashOAuthValue,
} from '../services/notion';
export const NOTION_OAUTH_CALLBACK_PATH = '/api/imports/notion/oauth/callback';
const idSchema = z.string().uuid();
const materializeSchema = z.object({ dataSourceId: z.string().min(1).max(200) }).strict();
export function createNotionImportRoutes(
config: Config,
db: Database,
client = new NotionClient(),
): Hono<ApiEnv> {
const routes = new Hono<ApiEnv>();
const oauthCookieName = config.isProduction ? '__Host-pig_notion_oauth' : 'pig_notion_oauth';
const oauthCookiePath = config.isProduction ? '/' : NOTION_OAUTH_CALLBACK_PATH;
routes.use('/api/imports/notion/*', async (context, next) => {
if (new URL(context.req.url).pathname === NOTION_OAUTH_CALLBACK_PATH) return next();
requireCapability(context.get('principal'), 'data:import');
await next();
});
routes.get('/api/imports/notion/status', async (context) => {
const principal = context.get('principal');
const connections = await db
.select()
.from(notionConnections)
.where(eq(notionConnections.userId, principal.userId));
return context.json({
configured: notionConfigured(config),
connected: connections.length > 0,
connections: connections.map(notionConnectionMetadata),
});
});
routes.post('/api/imports/notion/oauth/start', async (context) => {
assertNotionConfigured(config);
const principal = context.get('principal');
const now = new Date();
const attempt = createNotionOAuthAttempt(now);
await db.transaction(async (tx) => {
await tx.delete(notionOauthStates).where(lt(notionOauthStates.expiresAt, now));
await tx.insert(notionOauthStates).values({
stateHash: attempt.stateHash,
userId: principal.userId,
verifierHash: attempt.verifierHash,
expiresAt: attempt.expiresAt,
createdAt: now,
});
});
setCookie(context, oauthCookieName, attempt.verifier, {
httpOnly: true,
secure: config.isProduction,
sameSite: 'Lax',
path: oauthCookiePath,
maxAge: 600,
});
return context.json({
authorizationUrl: notionAuthorizationUrl({
clientId: config.NOTION_CLIENT_ID!,
redirectUri: config.NOTION_REDIRECT_URI!,
state: attempt.state,
}),
expiresAt: attempt.expiresAt.toISOString(),
});
});
routes.get(NOTION_OAUTH_CALLBACK_PATH, async (context) => {
if (!notionConfigured(config)) return context.json({ error: 'Notion import is not configured.' }, 503);
const state = context.req.query('state');
if (!state) return context.json({ error: 'Missing OAuth state.' }, 400);
const [attempt] = await db
.delete(notionOauthStates)
.where(eq(notionOauthStates.stateHash, hashOAuthValue(state)))
.returning();
const verifier = getCookie(context, oauthCookieName);
deleteCookie(context, oauthCookieName, {
path: oauthCookiePath,
secure: config.isProduction,
});
if (!attempt || !verifyNotionOAuthAttempt(verifier, attempt.verifierHash, attempt.expiresAt)) {
return context.json({ error: 'The Notion authorization attempt is invalid or expired.' }, 400);
}
const providerError = context.req.query('error');
if (providerError) return redirectToImports(context, config, 'denied');
const code = context.req.query('code');
if (!code) return context.json({ error: 'Missing Notion authorization code.' }, 400);
const token = await client.exchangeAuthorizationCode({
clientId: config.NOTION_CLIENT_ID!,
clientSecret: config.NOTION_CLIENT_SECRET!,
code,
redirectUri: config.NOTION_REDIRECT_URI!,
});
const accessToken = typeof token.access_token === 'string' ? token.access_token : null;
const workspaceId = typeof token.workspace_id === 'string' ? token.workspace_id : null;
if (!accessToken || !workspaceId) throw new NotionApiError(502, 'Notion returned an incomplete OAuth grant.');
const credentials: NotionCredentials = {
accessToken,
...(typeof token.refresh_token === 'string' ? { refreshToken: token.refresh_token } : {}),
...(typeof token.expires_in === 'number'
? { expiresAt: new Date(Date.now() + token.expires_in * 1_000).toISOString() }
: {}),
};
await db.insert(notionConnections).values({
userId: attempt.userId,
workspaceId,
workspaceName: typeof token.workspace_name === 'string' ? token.workspace_name : null,
workspaceIcon: typeof token.workspace_icon === 'string' ? token.workspace_icon : null,
botId: typeof token.bot_id === 'string' ? token.bot_id : null,
credentialsEncrypted: encryptSecret(
JSON.stringify(credentials),
config.PIG_SETTINGS_ENCRYPTION_KEY,
),
updatedAt: new Date(),
}).onConflictDoUpdate({
target: [notionConnections.userId, notionConnections.workspaceId],
set: {
workspaceName: typeof token.workspace_name === 'string' ? token.workspace_name : null,
workspaceIcon: typeof token.workspace_icon === 'string' ? token.workspace_icon : null,
botId: typeof token.bot_id === 'string' ? token.bot_id : null,
credentialsEncrypted: encryptSecret(
JSON.stringify(credentials),
config.PIG_SETTINGS_ENCRYPTION_KEY,
),
updatedAt: new Date(),
},
});
return redirectToImports(context, config, 'connected');
});
routes.get('/api/imports/notion/connections/:id/data-sources', async (context) => {
const connection = await ownedConnection(db, context.get('principal').userId, context.req.param('id'));
return context.json({ dataSources: await client.searchDataSources(credentials(config, connection).accessToken) });
});
routes.post('/api/imports/notion/connections/:id/materialize', async (context) => {
const input = materializeSchema.safeParse(await context.req.json());
if (!input.success) return context.json({ error: 'Invalid Notion data source.', issues: input.error.issues }, 400);
const connection = await ownedConnection(db, context.get('principal').userId, context.req.param('id'));
return context.json(await materializeNotionDataSource(
client,
credentials(config, connection).accessToken,
input.data.dataSourceId,
));
});
routes.delete('/api/imports/notion/connections/:id', async (context) => {
const id = idSchema.safeParse(context.req.param('id'));
if (!id.success) return context.json({ error: 'Invalid Notion connection.' }, 400);
const deleted = await db.delete(notionConnections).where(and(
eq(notionConnections.id, id.data),
eq(notionConnections.userId, context.get('principal').userId),
)).returning({ id: notionConnections.id });
if (!deleted[0]) return context.json({ error: 'Notion connection not found.' }, 404);
return context.json({ id: deleted[0].id, disconnected: true });
});
routes.onError((error, context) => {
if (error instanceof NotionApiError) {
const status = error.status >= 400 && error.status < 600 ? error.status : 502;
return context.json({ error: error.message }, status as 400);
}
throw error;
});
return routes;
}
export function notionConfigured(config: Config): boolean {
return Boolean(
config.NOTION_CLIENT_ID
&& config.NOTION_CLIENT_SECRET
&& config.NOTION_REDIRECT_URI
&& encryptionReady(config.PIG_SETTINGS_ENCRYPTION_KEY),
);
}
function assertNotionConfigured(config: Config): void {
if (!notionConfigured(config)) throw new NotionApiError(503, 'Notion import is not configured.');
}
async function ownedConnection(db: Database, userId: string, rawId: string) {
const id = idSchema.safeParse(rawId);
if (!id.success) throw new NotionApiError(400, 'Invalid Notion connection.');
const [connection] = await db.select().from(notionConnections).where(and(
eq(notionConnections.id, id.data),
eq(notionConnections.userId, userId),
)).limit(1);
if (!connection) throw new NotionApiError(404, 'Notion connection not found.');
return connection;
}
function credentials(config: Config, connection: { credentialsEncrypted: string }): NotionCredentials {
try {
const parsed: unknown = JSON.parse(decryptSecret(
connection.credentialsEncrypted,
config.PIG_SETTINGS_ENCRYPTION_KEY,
));
if (typeof parsed !== 'object' || parsed === null || !('accessToken' in parsed)
|| typeof parsed.accessToken !== 'string') throw new Error('invalid');
return parsed as NotionCredentials;
} catch {
throw new NotionApiError(503, 'The Notion connection cannot be decrypted. Reconnect it.');
}
}
function redirectToImports(context: Parameters<typeof deleteCookie>[0], config: Config, result: string) {
const url = new URL('/imports', config.PIG_PUBLIC_URL);
url.searchParams.set('notion', result);
return context.redirect(url.toString(), 302);
}
+134
View File
@@ -0,0 +1,134 @@
import { Hono } from 'hono';
import { stream } from 'hono/streaming';
import { z } from 'zod';
import type { ApiEnv } from '../lib/mutation';
const requestSchema = z
.object({
message: z.string().trim().min(1).max(4_000),
history: z
.array(
z.object({
role: z.enum(['user', 'assistant']),
content: z.string().min(1).max(8_000),
}),
)
.max(20)
.optional(),
context: z
.object({
type: z.enum([
'account',
'contact',
'demand_deal',
'supply_deal',
'contract',
'commitment',
]),
id: z.string().uuid(),
label: z.string().max(240).optional(),
})
.optional(),
})
.strict();
export interface PiggyChatProxyOptions {
enabled: boolean;
internalUrl?: string;
internalToken?: string;
fetchImpl?: typeof fetch;
}
export function createPiggyChatRoutes(options: PiggyChatProxyOptions) {
const routes = new Hono<ApiEnv>();
const fetchImpl = options.fetchImpl ?? fetch;
const available = Boolean(options.enabled && options.internalUrl && options.internalToken);
routes.get('/api/piggy/status', (c) => {
const principal = c.get('principal');
return c.json({
enabled: available,
canUse: available && principal.scopes.includes('read'),
});
});
routes.post('/api/piggy/chat', async (c) => {
const principal = c.get('principal');
if (!principal.scopes.includes('read')) {
return c.json(
{ error: "This credential lacks the 'read' scope.", code: 'insufficient_scope' },
403,
);
}
if (!available || !options.internalUrl || !options.internalToken) {
return c.json({ error: 'Piggy chat is not available.', code: 'piggy_unavailable' }, 503);
}
let raw: unknown;
try {
raw = await c.req.json();
} catch {
return c.json({ error: 'Request body must be valid JSON.', code: 'invalid_json' }, 400);
}
const parsed = requestSchema.safeParse(raw);
if (!parsed.success) {
return c.json(
{ error: 'Invalid Piggy chat request.', code: 'invalid_request', issues: parsed.error.issues },
400,
);
}
const upstream = await fetchImpl(
`${options.internalUrl.replace(/\/$/, '')}/internal/chat`,
{
method: 'POST',
headers: {
authorization: `Bearer ${options.internalToken}`,
'content-type': 'application/json',
accept: 'application/x-ndjson',
},
body: JSON.stringify({ principalUserId: principal.userId, ...parsed.data }),
signal: c.req.raw.signal,
},
);
if (!upstream.ok) {
const detail = await upstream.text().catch(() => '');
return c.json(
{
error: detail.slice(0, 500) || 'Piggy chat service did not respond.',
code: 'piggy_upstream_error',
},
502,
);
}
const upstreamBody = upstream.body;
if (!upstreamBody) {
return c.json(
{
error: 'Piggy chat service returned no response stream.',
code: 'piggy_upstream_error',
},
502,
);
}
c.header('content-type', 'application/x-ndjson; charset=utf-8');
c.header('cache-control', 'no-cache, no-transform');
c.header('x-accel-buffering', 'no');
return stream(c, async (output) => {
const reader = upstreamBody.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) return;
await output.write(value);
}
} finally {
reader.releaseLock();
}
});
});
return routes;
}
+711
View File
@@ -0,0 +1,711 @@
import {
ACCOUNT_SIDES,
AFFILIATION_KINDS,
CUSTOMER_SEGMENTS,
DEMAND_STAGES,
INTERCONNECT_TYPES,
PRODUCT_LINES,
SUPPLIER_TYPES,
SUPPLY_STAGES,
permissionGranted,
type AccountSide,
type Team,
} from '@pig/core';
import type { Database } from '@pig/db';
import { accounts, agentTasks, contacts, demandDeals, sites, supplyDeals } from '@pig/db';
import { desc, eq } from 'drizzle-orm';
import { Hono } from 'hono';
import { z } from 'zod';
import { AuthError, effectivePermissions, type Principal } from '../lib/auth';
import { MutationError, mutation, type ApiEnv, type MutationDefinition } from '../lib/mutation';
import type { NotificationOutbox } from '../services/notification-outbox';
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0];
const requiredText = (maximum: number) => z.string().trim().min(1).max(maximum);
const nullableText = (maximum: number) => z.string().trim().max(maximum).nullable().optional();
const nullableUrl = z.string().trim().url().max(400).nullable().optional();
const nullableUuid = z.string().uuid().nullable().optional();
const nullableDate = z
.union([z.string().date(), z.string().datetime()])
.nullable()
.optional()
.transform((value) => {
if (!value) return value === null ? null : undefined;
return new Date(value.length === 10 ? `${value}T12:00:00.000Z` : value);
});
const nullablePositiveInteger = z.number().int().positive().nullable().optional();
const nullableNonnegativeInteger = z.number().int().nonnegative().nullable().optional();
const accountFields = {
name: requiredText(200),
domain: nullableText(200).transform((value) => value?.toLowerCase() || value),
website: nullableUrl,
description: nullableText(4000),
side: z.enum(ACCOUNT_SIDES),
supplierType: z.enum(SUPPLIER_TYPES).nullable().optional(),
customerSegment: z.enum(CUSTOMER_SEGMENTS).nullable().optional(),
country: nullableText(100),
region: nullableText(100),
jurisdiction: nullableText(100),
ultimateParentName: nullableText(200),
ultimateParentCountry: nullableText(100),
};
const accountCreateSchema = z.object(accountFields).strict();
const accountUpdateSchema = z.object(accountFields).partial().strict();
const contactFields = {
accountId: z.string().uuid(),
fullName: requiredText(200),
firstName: nullableText(100),
lastName: nullableText(100),
title: nullableText(200),
email: z.string().trim().email().max(320).nullable().optional(),
phone: nullableText(80),
linkedinUrl: nullableUrl,
twitterHandle: nullableText(100),
githubHandle: nullableText(100),
websiteUrl: nullableUrl,
affiliation: z.enum(AFFILIATION_KINDS),
isDecisionMaker: z.boolean(),
confidenceNote: nullableText(2000),
};
const contactCreateSchema = z.object(contactFields).strict();
const contactUpdateSchema = z.object(contactFields).partial().strict();
const demandDealFields = {
accountId: z.string().uuid(),
name: requiredText(240),
description: nullableText(4000),
productLine: z.enum(PRODUCT_LINES),
stage: z.enum(DEMAND_STAGES),
primaryContactId: nullableUuid,
acvCents: nullableNonnegativeInteger,
tcvCents: nullableNonnegativeInteger,
currency: z.string().trim().toUpperCase().regex(/^[A-Z]{3}$/),
termMonths: nullablePositiveInteger,
probability: z.number().min(0).max(1).nullable().optional(),
expectedCloseDate: nullableDate,
closedReason: nullableText(2000),
msaExecuted: z.boolean(),
dpaExecuted: z.boolean(),
parentDealId: nullableUuid,
};
const demandDealCreateSchema = z.object(demandDealFields).strict();
const demandDealUpdateSchema = z.object(demandDealFields).partial().strict();
const supplyDealFields = {
accountId: z.string().uuid(),
siteId: nullableUuid,
name: requiredText(240),
stage: z.enum(SUPPLY_STAGES),
primaryContactId: nullableUuid,
gpuType: nullableText(100),
gpuCount: nullablePositiveInteger,
interconnectType: z.enum(INTERCONNECT_TYPES).nullable().optional(),
targetCostPerGpuHourCents: nullableNonnegativeInteger,
termMonths: nullablePositiveInteger,
availableFrom: nullableDate,
technicalVerdict: nullableText(500),
technicalNotes: nullableText(4000),
financialVerdict: nullableText(500),
financialNotes: nullableText(4000),
rejectionReason: nullableText(2000),
};
const supplyDealCreateSchema = z.object(supplyDealFields).strict();
const supplyDealUpdateSchema = z.object(supplyDealFields).partial().strict();
export function accountSupportsTeam(side: AccountSide, team: Team): boolean {
return team !== 'research' && (side === 'both' || side === team);
}
function hasDealPermission(principal: Principal, team: 'supply' | 'demand'): boolean {
return permissionGranted(effectivePermissions(principal), 'deal:write', team);
}
function requireAnyDealPermission(principal: Principal): void {
if (hasDealPermission(principal, 'supply') || hasDealPermission(principal, 'demand')) return;
throw new AuthError(
"This principal lacks the 'deal:write' capability for supply or demand.",
403,
'insufficient_permission',
);
}
function requireAccountPermission(principal: Principal, side: AccountSide): void {
if (
(accountSupportsTeam(side, 'supply') && hasDealPermission(principal, 'supply')) ||
(accountSupportsTeam(side, 'demand') && hasDealPermission(principal, 'demand'))
) {
return;
}
throw new AuthError(
`This principal cannot write ${side}-side records.`,
403,
'insufficient_permission',
);
}
async function findAccount(tx: Transaction, id: string) {
const [account] = await tx.select().from(accounts).where(eq(accounts.id, id)).limit(1);
if (!account) throw MutationError.notFound('Account');
return account;
}
async function requireAccountForTeam(tx: Transaction, id: string, team: 'supply' | 'demand') {
const account = await findAccount(tx, id);
if (!accountSupportsTeam(account.side, team)) {
throw new MutationError(
'relationship_mismatch',
`The selected account is not on the ${team} side.`,
409,
);
}
return account;
}
async function requireContactForAccount(
tx: Transaction,
contactId: string | null | undefined,
accountId: string,
): Promise<void> {
if (!contactId) return;
const [contact] = await tx.select().from(contacts).where(eq(contacts.id, contactId)).limit(1);
if (!contact) throw MutationError.notFound('Contact');
if (contact.accountId !== accountId) {
throw new MutationError(
'relationship_mismatch',
'The primary contact must belong to the selected account.',
409,
);
}
}
async function touchAccount(tx: Transaction, accountId: string, now: Date): Promise<void> {
await tx
.update(accounts)
.set({ lastActivityAt: now, updatedAt: now })
.where(eq(accounts.id, accountId));
}
const anyDealPermission = { authorize: requireAnyDealPermission } as const;
export function createAccountMutationDefinition(): MutationDefinition<
typeof accountCreateSchema,
typeof accounts.$inferSelect
> {
return {
schema: accountCreateSchema,
permission: anyDealPermission,
invalidMessage: 'Invalid account.',
async mutate({ input, principal, tx, now }) {
requireAccountPermission(principal, input.side);
const [created] = await tx
.insert(accounts)
.values({
...input,
ownerUserId: principal.userId,
source: 'manual',
lastActivityAt: now,
updatedAt: now,
})
.returning();
if (!created) throw new MutationError('write_failed', 'Account was not created.', 409);
await tx.insert(agentTasks).values({
kind: 'enrich_account',
subject: created.id,
reason: `New account created by ${principal.name}`,
requestedByUserId: principal.userId,
});
return {
data: created,
activity: {
type: 'note',
subject: `Created account — ${created.name}`,
accountId: created.id,
meta: { action: 'created', recordType: 'account', side: created.side },
},
};
},
};
}
function updateAccountMutationDefinition(): MutationDefinition<
typeof accountUpdateSchema,
typeof accounts.$inferSelect
> {
return {
schema: accountUpdateSchema,
permission: anyDealPermission,
invalidMessage: 'Invalid account update.',
async mutate({ input, principal, params, tx, now }) {
if (!params.id) throw MutationError.notFound('Account');
const before = await findAccount(tx, params.id);
const nextSide = input.side ?? before.side;
// Both checks matter when a record crosses sides: access to the source
// record must not grant permission to move it into another team's book.
requireAccountPermission(principal, before.side);
requireAccountPermission(principal, nextSide);
const [updated] = await tx
.update(accounts)
.set({ ...input, updatedAt: now, lastActivityAt: now })
.where(eq(accounts.id, before.id))
.returning();
if (!updated) throw MutationError.notFound('Account');
return {
data: updated,
activity: {
type: 'note',
subject: `Updated account — ${updated.name}`,
accountId: updated.id,
meta: { action: 'updated', recordType: 'account', fromSide: before.side, side: updated.side },
},
};
},
};
}
function createContactMutationDefinition(): MutationDefinition<
typeof contactCreateSchema,
typeof contacts.$inferSelect
> {
return {
schema: contactCreateSchema,
permission: anyDealPermission,
invalidMessage: 'Invalid contact.',
async mutate({ input, principal, tx, now }) {
const account = await findAccount(tx, input.accountId);
requireAccountPermission(principal, account.side);
const [created] = await tx
.insert(contacts)
.values({
...input,
ownerUserId: principal.userId,
source: 'manual',
lastActivityAt: now,
updatedAt: now,
})
.returning();
if (!created) throw new MutationError('write_failed', 'Contact was not created.', 409);
await touchAccount(tx, account.id, now);
await tx.insert(agentTasks).values({
kind: 'enrich_contact',
subject: created.id,
reason: `New contact created by ${principal.name}`,
requestedByUserId: principal.userId,
});
return {
data: created,
activity: {
type: 'note',
subject: `Created contact — ${created.fullName}`,
accountId: account.id,
contactId: created.id,
meta: { action: 'created', recordType: 'contact', side: account.side },
},
};
},
};
}
function updateContactMutationDefinition(): MutationDefinition<
typeof contactUpdateSchema,
typeof contacts.$inferSelect
> {
return {
schema: contactUpdateSchema,
permission: anyDealPermission,
invalidMessage: 'Invalid contact update.',
async mutate({ input, principal, params, tx, now }) {
if (!params.id) throw MutationError.notFound('Contact');
const [before] = await tx.select().from(contacts).where(eq(contacts.id, params.id)).limit(1);
if (!before) throw MutationError.notFound('Contact');
if (!before.accountId && !input.accountId) {
throw new MutationError(
'relationship_required',
'Select an account before editing this contact.',
409,
);
}
if (before.accountId) {
const beforeAccount = await findAccount(tx, before.accountId);
requireAccountPermission(principal, beforeAccount.side);
}
const account = await findAccount(tx, input.accountId ?? before.accountId!);
requireAccountPermission(principal, account.side);
const [updated] = await tx
.update(contacts)
.set({ ...input, updatedAt: now, lastActivityAt: now })
.where(eq(contacts.id, before.id))
.returning();
if (!updated) throw MutationError.notFound('Contact');
await touchAccount(tx, account.id, now);
return {
data: updated,
activity: {
type: 'note',
subject: `Updated contact — ${updated.fullName}`,
accountId: account.id,
contactId: updated.id,
meta: { action: 'updated', recordType: 'contact', side: account.side },
},
};
},
};
}
export function createDemandDealMutationDefinition(): MutationDefinition<
typeof demandDealCreateSchema,
typeof demandDeals.$inferSelect
> {
return {
schema: demandDealCreateSchema,
permission: { capability: 'deal:write', team: 'demand' },
invalidMessage: 'Invalid demand deal.',
async mutate({ input, principal, tx, now }) {
const account = await requireAccountForTeam(tx, input.accountId, 'demand');
await requireContactForAccount(tx, input.primaryContactId, account.id);
if (input.parentDealId) {
const [parent] = await tx
.select()
.from(demandDeals)
.where(eq(demandDeals.id, input.parentDealId))
.limit(1);
if (!parent) throw MutationError.notFound('Parent demand deal');
if (parent.accountId !== account.id) {
throw new MutationError(
'relationship_mismatch',
'A parent deal must belong to the selected account.',
409,
);
}
}
const closedAt = ['closed_won', 'closed_lost'].includes(input.stage) ? now : null;
const probability =
input.probability === undefined
? undefined
: input.probability === null
? null
: String(input.probability);
const [created] = await tx
.insert(demandDeals)
.values({
...input,
probability,
ownerUserId: principal.userId,
stageChangedAt: now,
closedAt,
lastActivityAt: now,
updatedAt: now,
})
.returning();
if (!created) throw new MutationError('write_failed', 'Demand deal was not created.', 409);
await touchAccount(tx, account.id, now);
return {
data: created,
activity: {
type: 'note',
subject: `Created demand deal — ${created.name}`,
accountId: account.id,
demandDealId: created.id,
meta: { action: 'created', recordType: 'demand_deal', stage: created.stage },
},
};
},
};
}
function updateDemandDealMutationDefinition(notifications?: NotificationOutbox): MutationDefinition<
typeof demandDealUpdateSchema,
typeof demandDeals.$inferSelect
> {
return {
schema: demandDealUpdateSchema,
permission: { capability: 'deal:write', team: 'demand' },
invalidMessage: 'Invalid demand deal update.',
async mutate({ input, params, tx, now }) {
if (!params.id) throw MutationError.notFound('Demand deal');
const [before] = await tx
.select()
.from(demandDeals)
.where(eq(demandDeals.id, params.id))
.limit(1);
if (!before) throw MutationError.notFound('Demand deal');
const accountId = input.accountId ?? before.accountId;
const account = await requireAccountForTeam(tx, accountId, 'demand');
const contactId = input.primaryContactId === undefined
? before.primaryContactId
: input.primaryContactId;
await requireContactForAccount(tx, contactId, account.id);
const parentId = input.parentDealId === undefined ? before.parentDealId : input.parentDealId;
if (parentId) {
if (parentId === before.id) {
throw new MutationError('relationship_mismatch', 'A deal cannot be its own parent.', 409);
}
const [parent] = await tx.select().from(demandDeals).where(eq(demandDeals.id, parentId)).limit(1);
if (!parent) throw MutationError.notFound('Parent demand deal');
if (parent.accountId !== account.id) {
throw new MutationError(
'relationship_mismatch',
'A parent deal must belong to the selected account.',
409,
);
}
}
const stage = input.stage ?? before.stage;
const stageChanged = stage !== before.stage;
const probability =
input.probability === undefined
? undefined
: input.probability === null
? null
: String(input.probability);
const closedAt = stageChanged
? ['closed_won', 'closed_lost'].includes(stage)
? now
: null
: before.closedAt;
const [updated] = await tx
.update(demandDeals)
.set({
...input,
probability,
stageChangedAt: stageChanged ? now : before.stageChangedAt,
closedAt,
updatedAt: now,
lastActivityAt: now,
})
.where(eq(demandDeals.id, before.id))
.returning();
if (!updated) throw MutationError.notFound('Demand deal');
await touchAccount(tx, account.id, now);
if (stageChanged) {
await notifications?.enqueueStageChange(tx, {
accountId: account.id,
dealId: updated.id,
dealSide: 'demand',
dealName: updated.name,
fromStage: before.stage,
toStage: updated.stage,
changedAt: now.toISOString(),
});
}
return {
data: updated,
activity: {
type: stageChanged ? 'stage_change' : 'note',
subject: stageChanged
? `${before.stage}${updated.stage}`
: `Updated demand deal — ${updated.name}`,
accountId: account.id,
demandDealId: updated.id,
meta: {
action: 'updated',
recordType: 'demand_deal',
fromStage: before.stage,
stage: updated.stage,
},
},
};
},
};
}
function createSupplyDealMutationDefinition(): MutationDefinition<
typeof supplyDealCreateSchema,
typeof supplyDeals.$inferSelect
> {
return {
schema: supplyDealCreateSchema,
permission: { capability: 'deal:write', team: 'supply' },
invalidMessage: 'Invalid supply deal.',
async mutate({ input, principal, tx, now }) {
const account = await requireAccountForTeam(tx, input.accountId, 'supply');
await requireContactForAccount(tx, input.primaryContactId, account.id);
if (input.siteId) {
const [site] = await tx.select().from(sites).where(eq(sites.id, input.siteId)).limit(1);
if (!site) throw MutationError.notFound('Site');
if (site.accountId !== account.id) {
throw new MutationError(
'relationship_mismatch',
'The selected site must belong to the selected account.',
409,
);
}
}
const closedAt = ['live', 'churned', 'rejected'].includes(input.stage) ? now : null;
const [created] = await tx
.insert(supplyDeals)
.values({
...input,
ownerUserId: principal.userId,
stageChangedAt: now,
closedAt,
lastActivityAt: now,
updatedAt: now,
})
.returning();
if (!created) throw new MutationError('write_failed', 'Supply deal was not created.', 409);
await touchAccount(tx, account.id, now);
return {
data: created,
activity: {
type: 'note',
subject: `Created supply deal — ${created.name}`,
accountId: account.id,
supplyDealId: created.id,
meta: { action: 'created', recordType: 'supply_deal', stage: created.stage },
},
};
},
};
}
function updateSupplyDealMutationDefinition(notifications?: NotificationOutbox): MutationDefinition<
typeof supplyDealUpdateSchema,
typeof supplyDeals.$inferSelect
> {
return {
schema: supplyDealUpdateSchema,
permission: { capability: 'deal:write', team: 'supply' },
invalidMessage: 'Invalid supply deal update.',
async mutate({ input, principal, params, tx, now }) {
if (!params.id) throw MutationError.notFound('Supply deal');
const [before] = await tx.select().from(supplyDeals).where(eq(supplyDeals.id, params.id)).limit(1);
if (!before) throw MutationError.notFound('Supply deal');
const accountId = input.accountId ?? before.accountId;
const account = await requireAccountForTeam(tx, accountId, 'supply');
const contactId = input.primaryContactId === undefined
? before.primaryContactId
: input.primaryContactId;
await requireContactForAccount(tx, contactId, account.id);
const siteId = input.siteId === undefined ? before.siteId : input.siteId;
if (siteId) {
const [site] = await tx.select().from(sites).where(eq(sites.id, siteId)).limit(1);
if (!site) throw MutationError.notFound('Site');
if (site.accountId !== account.id) {
throw new MutationError(
'relationship_mismatch',
'The selected site must belong to the selected account.',
409,
);
}
}
const stage = input.stage ?? before.stage;
const stageChanged = stage !== before.stage;
const closedAt = stageChanged
? ['live', 'churned', 'rejected'].includes(stage)
? now
: null
: before.closedAt;
const technicalVerdictChanged =
input.technicalVerdict !== undefined &&
input.technicalVerdict !== before.technicalVerdict;
const financialVerdictChanged =
input.financialVerdict !== undefined &&
input.financialVerdict !== before.financialVerdict;
const verdictUpdate = {
technicalVerdictBy: technicalVerdictChanged
? input.technicalVerdict === null
? null
: principal.userId
: undefined,
technicalVerdictAt: technicalVerdictChanged
? input.technicalVerdict === null
? null
: now
: undefined,
financialVerdictBy: financialVerdictChanged
? input.financialVerdict === null
? null
: principal.userId
: undefined,
financialVerdictAt: financialVerdictChanged
? input.financialVerdict === null
? null
: now
: undefined,
};
const [updated] = await tx
.update(supplyDeals)
.set({
...input,
...verdictUpdate,
stageChangedAt: stageChanged ? now : before.stageChangedAt,
closedAt,
updatedAt: now,
lastActivityAt: now,
})
.where(eq(supplyDeals.id, before.id))
.returning();
if (!updated) throw MutationError.notFound('Supply deal');
await touchAccount(tx, account.id, now);
if (stageChanged) {
await notifications?.enqueueStageChange(tx, {
accountId: account.id,
dealId: updated.id,
dealSide: 'supply',
dealName: updated.name,
fromStage: before.stage,
toStage: updated.stage,
changedAt: now.toISOString(),
});
}
return {
data: updated,
activity: {
type: stageChanged ? 'stage_change' : 'note',
subject: stageChanged
? `${before.stage}${updated.stage}`
: `Updated supply deal — ${updated.name}`,
accountId: account.id,
supplyDealId: updated.id,
meta: {
action: 'updated',
recordType: 'supply_deal',
fromStage: before.stage,
stage: updated.stage,
},
},
};
},
};
}
export function createRecordRoutes(db: Database, notifications?: NotificationOutbox): Hono<ApiEnv> {
const app = new Hono<ApiEnv>();
app.get('/api/contacts', async (c) => {
const accountId = c.req.query('accountId');
const rows = await db
.select({ contact: contacts, accountName: accounts.name, accountSide: accounts.side })
.from(contacts)
.leftJoin(accounts, eq(accounts.id, contacts.accountId))
.where(accountId ? eq(contacts.accountId, accountId) : undefined)
.orderBy(desc(contacts.lastActivityAt), contacts.fullName)
.limit(500);
return c.json(rows);
});
app.post('/api/accounts', mutation(db, createAccountMutationDefinition()));
app.patch('/api/accounts/:id', mutation(db, updateAccountMutationDefinition()));
app.post('/api/contacts', mutation(db, createContactMutationDefinition()));
app.patch('/api/contacts/:id', mutation(db, updateContactMutationDefinition()));
app.post('/api/deals/demand', mutation(db, createDemandDealMutationDefinition()));
app.patch('/api/deals/demand/:id', mutation(db, updateDemandDealMutationDefinition(notifications)));
app.post('/api/deals/supply', mutation(db, createSupplyDealMutationDefinition()));
app.patch('/api/deals/supply/:id', mutation(db, updateSupplyDealMutationDefinition(notifications)));
return app;
}
+11 -13
View File
@@ -23,7 +23,6 @@
* an account, and why the server warns at boot.
*/
import { Hono } from 'hono';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { and, eq, gt, isNull, or, sql } from 'drizzle-orm';
import { z } from 'zod';
import { createHash } from 'node:crypto';
@@ -31,6 +30,7 @@ import type { Database } from '@pig/db';
import { invites, teamMemberships, users } from '@pig/db';
import { TEAMS } from '@pig/core';
import type { Config } from '../lib/config';
import type { AuthProvider } from '../lib/auth-provider';
const bodySchema = z.object({
name: z.string().min(1).max(120),
@@ -39,13 +39,13 @@ const bodySchema = z.object({
title: z.string().max(160).optional(),
});
export function createSignupRoute(config: Config, db: Database) {
export function createSignupRoute(
config: Config,
db: Database,
authProvider: AuthProvider | null,
) {
const app = new Hono();
const jwks = config.SUPABASE_URL
? createRemoteJWKSet(new URL(`${config.SUPABASE_URL}/auth/v1/.well-known/jwks.json`))
: null;
app.post('/api/signup', async (c) => {
const parsed = bodySchema.safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) {
@@ -61,7 +61,7 @@ export function createSignupRoute(config: Config, db: Database) {
let subject: string;
let email: string;
if (!config.SUPABASE_URL) {
if (!authProvider) {
// Development only. `loadConfig` refuses to start in production without
// an identity provider, so this branch cannot exist in a real deployment.
if (config.isProduction) {
@@ -71,14 +71,12 @@ export function createSignupRoute(config: Config, db: Database) {
email = 'dev@localhost';
} else {
try {
const { payload } = await jwtVerify(header.slice(7).trim(), jwks!, {
issuer: `${config.SUPABASE_URL}/auth/v1`,
});
if (!payload.sub || typeof payload.email !== 'string') {
const identity = await authProvider.verifyAccessToken(header.slice(7).trim());
if (!identity.subject || typeof identity.email !== 'string') {
throw new Error('token missing subject or email');
}
subject = payload.sub;
email = payload.email.toLowerCase();
subject = identity.subject;
email = identity.email.toLowerCase();
} catch {
return c.json({ error: 'Invalid or expired token.', code: 'invalid_token' }, 401);
}
+271
View File
@@ -0,0 +1,271 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
import { NOTIFICATION_KINDS, SECURITY_TIERS } from '@pig/core';
import type { Database } from '@pig/db';
import { accounts, channelLinks, notificationOutbox } from '@pig/db';
import { and, desc, eq, inArray } from 'drizzle-orm';
import { Hono, type Context } from 'hono';
import { z } from 'zod';
import type { Config } from '../lib/config';
import { requireCapability } from '../lib/auth';
import {
bodylessMutation,
MutationError,
mutation,
type ApiEnv,
type MutationDefinition,
} from '../lib/mutation';
import type { CapacityService } from '../services/capacity';
export const SLACK_CAPACITY_COMMAND_PATH = '/api/integrations/slack/commands/capacity-match';
const SLACK_REPLAY_WINDOW_SECONDS = 5 * 60;
const slackLinkSchema = z
.object({
workspaceId: z.string().trim().min(1).max(80),
channelId: z.string().trim().min(1).max(80),
channelName: z.string().trim().min(1).max(120).nullable().optional(),
accountId: z.string().uuid(),
notifyOn: z.array(z.enum(NOTIFICATION_KINDS)).min(1).default([...NOTIFICATION_KINDS]),
})
.strict();
const slashFormSchema = z.object({
team_id: z.string().min(1),
channel_id: z.string().min(1),
user_id: z.string().min(1),
text: z.string().default(''),
});
const slashRequirementSchema = z.object({
gpuCount: z.number().int().positive(),
gpuType: z.string().min(1),
totalGpuHours: z.number().positive().optional(),
requiresHighSpeedInterconnect: z.boolean().optional(),
minSecurityTier: z.enum(SECURITY_TIERS).optional(),
maxPricePerGpuHourCents: z.number().int().positive().optional(),
});
const emptyMutationSchema = z.object({}).strict();
export function createSlackLinkMutationDefinition(): MutationDefinition<
typeof slackLinkSchema,
typeof channelLinks.$inferSelect
> {
return {
schema: slackLinkSchema,
permission: { capability: 'settings:admin' },
invalidMessage: 'Invalid Slack channel link.',
async mutate({ input, principal, tx, now }) {
const [account] = await tx
.select({ id: accounts.id, name: accounts.name })
.from(accounts)
.where(eq(accounts.id, input.accountId))
.limit(1);
if (!account) throw MutationError.notFound('Account');
const [link] = await tx
.insert(channelLinks)
.values({
...input,
platform: 'slack',
linkedByUserId: principal.userId,
updatedAt: now,
})
.onConflictDoUpdate({
target: [channelLinks.platform, channelLinks.workspaceId, channelLinks.channelId],
set: {
accountId: input.accountId,
channelName: input.channelName,
notifyOn: input.notifyOn,
linkedByUserId: principal.userId,
updatedAt: now,
},
})
.returning();
if (!link) throw new MutationError('write_failed', 'Slack channel was not linked.', 409);
return {
data: link,
activity: {
type: 'slack',
subject: `Linked Slack channel to ${account.name}`,
accountId: account.id,
meta: { action: 'linked', integration: 'slack', channelId: link.channelId },
},
};
},
};
}
function createSlackUnlinkMutationDefinition(): MutationDefinition<
typeof emptyMutationSchema,
{ deleted: true }
> {
return {
schema: emptyMutationSchema,
permission: { capability: 'settings:admin' },
invalidMessage: 'Invalid Slack unlink request.',
async mutate({ params, tx }) {
if (!params.id) throw MutationError.notFound('Slack channel link');
const [link] = await tx
.select()
.from(channelLinks)
.where(and(eq(channelLinks.id, params.id), eq(channelLinks.platform, 'slack')))
.limit(1);
if (!link) throw MutationError.notFound('Slack channel link');
await tx
.update(notificationOutbox)
.set({ status: 'cancelled', leasedBy: null, leasedUntil: null })
.where(
and(
eq(notificationOutbox.linkId, link.id),
inArray(notificationOutbox.status, ['pending', 'leased']),
),
);
await tx.delete(channelLinks).where(eq(channelLinks.id, link.id));
return {
data: { deleted: true as const },
activity: {
type: 'slack',
subject: 'Unlinked Slack channel',
accountId: link.accountId ?? undefined,
meta: { action: 'unlinked', integration: 'slack', channelId: link.channelId },
},
};
},
};
}
export function createSlackRoutes(
config: Config,
db: Database,
capacity: CapacityService,
): Hono<ApiEnv> {
const app = new Hono<ApiEnv>();
app.post(SLACK_CAPACITY_COMMAND_PATH, async (c) => {
const rawBody = await c.req.text();
if (
!config.SLACK_SIGNING_SECRET ||
!verifySlackRequest(
config.SLACK_SIGNING_SECRET,
c.req.header('x-slack-request-timestamp'),
c.req.header('x-slack-signature'),
rawBody,
)
) {
return c.json({ error: 'Invalid Slack signature.' }, 401);
}
const form = slashFormSchema.safeParse(Object.fromEntries(new URLSearchParams(rawBody)));
if (!form.success) return slackEphemeral(c, 'Slack sent an incomplete command request.');
const [link] = await db
.select({ id: channelLinks.id })
.from(channelLinks)
.where(
and(
eq(channelLinks.platform, 'slack'),
eq(channelLinks.workspaceId, form.data.team_id),
eq(channelLinks.channelId, form.data.channel_id),
),
)
.limit(1);
if (!link) {
return slackEphemeral(c, 'Link this channel to a PIG account before matching capacity.');
}
const requirement = parseSlashRequirement(form.data.text);
if (!requirement) {
return slackEphemeral(
c,
'Usage: `/pig-capacity 8 H100_80GB hours=640 max=2.50 fabric tier=secure_cloud`',
);
}
const matches = await capacity.match(requirement);
return slackEphemeral(c, formatCapacityMatches(requirement, matches));
});
app.get('/api/integrations/slack/channel-links', async (c) => {
requireCapability(c.get('principal'), 'settings:admin');
const links = await db
.select({ link: channelLinks, accountName: accounts.name })
.from(channelLinks)
.innerJoin(accounts, eq(accounts.id, channelLinks.accountId))
.where(eq(channelLinks.platform, 'slack'))
.orderBy(desc(channelLinks.updatedAt));
return c.json(links);
});
app.post(
'/api/integrations/slack/channel-links',
mutation(db, createSlackLinkMutationDefinition()),
);
app.delete(
'/api/integrations/slack/channel-links/:id',
bodylessMutation(db, createSlackUnlinkMutationDefinition()),
);
return app;
}
export function verifySlackRequest(
signingSecret: string,
timestampHeader: string | undefined,
signatureHeader: string | undefined,
rawBody: string,
nowSeconds = Math.floor(Date.now() / 1_000),
): boolean {
if (!timestampHeader || !signatureHeader || !/^v0=[a-f0-9]{64}$/i.test(signatureHeader)) {
return false;
}
const timestamp = Number(timestampHeader);
if (!Number.isSafeInteger(timestamp)) return false;
if (Math.abs(nowSeconds - timestamp) > SLACK_REPLAY_WINDOW_SECONDS) return false;
const expected = `v0=${createHmac('sha256', signingSecret)
.update(`v0:${timestampHeader}:${rawBody}`)
.digest('hex')}`;
const actualBuffer = Buffer.from(signatureHeader, 'utf8');
const expectedBuffer = Buffer.from(expected, 'utf8');
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
}
export function parseSlashRequirement(text: string): z.infer<typeof slashRequirementSchema> | null {
const tokens = text.trim().split(/\s+/).filter(Boolean);
const countToken = tokens.shift();
const gpuType = tokens.shift();
if (!countToken || !gpuType) return null;
const gpuCount = Number(countToken.replace(/x$/i, ''));
const candidate: Record<string, unknown> = { gpuCount, gpuType };
for (const token of tokens) {
if (token === 'fabric') candidate.requiresHighSpeedInterconnect = true;
else if (token.startsWith('hours=')) candidate.totalGpuHours = Number(token.slice(6));
else if (token.startsWith('max=')) {
candidate.maxPricePerGpuHourCents = Math.round(Number(token.slice(4)) * 100);
} else if (token.startsWith('tier=')) candidate.minSecurityTier = token.slice(5);
else return null;
}
const parsed = slashRequirementSchema.safeParse(candidate);
return parsed.success ? parsed.data : null;
}
function formatCapacityMatches(
requirement: z.infer<typeof slashRequirementSchema>,
matches: Awaited<ReturnType<CapacityService['match']>>,
): string {
if (matches.length === 0) {
return `No sellable capacity matches ${requirement.gpuCount}× ${requirement.gpuType}.`;
}
const lines = matches.slice(0, 5).map((match, index) => {
const breakEven =
match.breakEvenPriceCents == null
? 'break-even unavailable'
: `$${(match.breakEvenPriceCents / 100).toFixed(2)}/GPU-hour break-even`;
return `${index + 1}. *${match.name}* — ${match.gpuCount}× ${match.gpuType}, ${Math.round(match.availableGpuHours)} hours available, ${breakEven}, ${Math.round(match.score * 100)}% fit`;
});
return [`*Best capacity matches for ${requirement.gpuCount}× ${requirement.gpuType}*`, ...lines].join('\n');
}
function slackEphemeral(c: Context<ApiEnv>, text: string) {
return c.json({ response_type: 'ephemeral', text });
}
+45 -2
View File
@@ -16,10 +16,19 @@ import { loadConfig } from './lib/config';
import { startPrimeSync } from './services/sync';
import { reconcileInviteCode } from './services/bootstrap';
import { CapacityService } from './services/capacity';
import { effectivePrimeSyncConfig } from './routes/admin-settings';
import { NotificationOutbox, NotificationWorker } from './services/notification-outbox';
import { SlackNotifier } from './services/slack';
import { BuzzNotifier } from './services/buzz';
const config = loadConfig();
const db = createDatabase({ url: config.DATABASE_URL });
const app = createApp(config, db);
let stopSync = () => {};
async function reloadPrimeSync(): Promise<void> {
stopSync();
stopSync = startPrimeSync(await effectivePrimeSyncConfig(config, db), db);
}
const app = createApp(config, db, undefined, { onPlatformSettingsChanged: reloadPrimeSync });
// Serve the built SPA when it exists. Absent in development, where Vite serves
// it on its own port with hot reload.
@@ -67,9 +76,40 @@ const server = serve({ fetch: app.fetch, port: config.PIG_PORT }, (info) => {
// Background work. Both are optional and the application is fully usable with
// neither running.
const stopSync = startPrimeSync(config, db);
await reloadPrimeSync();
const capacity = new CapacityService(db);
const notificationOutbox = new NotificationOutbox(db);
const slackNotificationsEnabled = Boolean(config.SLACK_BOT_TOKEN);
const buzzNotificationsEnabled = Boolean(config.BUZZ_RELAY_URL && config.BUZZ_PRIVATE_KEY);
const stopSlackNotifications = config.SLACK_BOT_TOKEN
? new NotificationWorker(db, new SlackNotifier({ botToken: config.SLACK_BOT_TOKEN })).start()
: () => {};
const stopBuzzNotifications = config.BUZZ_RELAY_URL && config.BUZZ_PRIVATE_KEY
? new NotificationWorker(
db,
new BuzzNotifier({
relayUrl: config.BUZZ_RELAY_URL,
privateKey: config.BUZZ_PRIVATE_KEY,
authTag: config.BUZZ_AUTH_TAG,
}),
).start()
: () => {};
let checkingIdleCapacity = false;
const checkIdleCapacity = () => {
if (checkingIdleCapacity || (!slackNotificationsEnabled && !buzzNotificationsEnabled)) return;
checkingIdleCapacity = true;
void capacity
.idleCapacity({ thresholdPct: 0.25, withinDays: 30 })
.then((rows) => notificationOutbox.enqueueIdleCapacity(rows))
.catch((error) => console.error('[pig] idle-capacity notification queue failed', error))
.finally(() => {
checkingIdleCapacity = false;
});
};
const idleNotificationTimer = setInterval(checkIdleCapacity, 6 * 60 * 60 * 1_000);
idleNotificationTimer.unref();
checkIdleCapacity();
const holdSweeper = setInterval(
() => {
void capacity
@@ -87,6 +127,9 @@ const holdSweeper = setInterval(
function shutdown(signal: string) {
console.log(`[pig] ${signal} received, shutting down`);
clearInterval(holdSweeper);
clearInterval(idleNotificationTimer);
stopSlackNotifications();
stopBuzzNotifications();
stopSync();
server.close(() => process.exit(0));
// Do not hang forever if a connection refuses to drain.
+311
View File
@@ -0,0 +1,311 @@
import { createHash, randomUUID } from 'node:crypto';
import { schnorr } from '@noble/curves/secp256k1.js';
import { finalizeEvent, getPublicKey, nip19, type Event, verifyEvent } from 'nostr-tools';
import {
NotificationDeliveryError,
type Notification,
type NotificationEnvelope,
type NotificationReceipt,
type Notifier,
} from './notifier';
interface BuzzNotifierOptions {
relayUrl: string;
privateKey: string;
authTag?: string;
fetchImpl?: typeof fetch;
now?: () => Date;
nonce?: () => string;
}
interface BuzzRelayResponse {
event_id?: string;
accepted?: boolean;
}
export class BuzzNotifier implements Notifier {
readonly provider = 'buzz';
readonly relayUrl: string;
readonly workspaceId: string;
private readonly eventsUrl: string;
private readonly secretKey: Uint8Array;
private readonly publicKey: string;
private readonly authTag: string[] | null;
private readonly authTagJson: string | null;
private readonly fetchImpl: typeof fetch;
private readonly now: () => Date;
private readonly nonce: () => string;
constructor(options: BuzzNotifierOptions) {
this.relayUrl = normaliseBuzzRelayUrl(options.relayUrl);
this.workspaceId = new URL(this.relayUrl).host;
this.eventsUrl = `${this.relayUrl}/events`;
this.secretKey = parseBuzzPrivateKey(options.privateKey);
this.publicKey = getPublicKey(this.secretKey);
const parsedAuth = options.authTag
? parseAndVerifyBuzzAuthTag(options.authTag, this.publicKey)
: null;
this.authTag = parsedAuth;
this.authTagJson = parsedAuth ? JSON.stringify(parsedAuth) : null;
this.fetchImpl = options.fetchImpl ?? fetch;
this.now = options.now ?? (() => new Date());
this.nonce = options.nonce ?? randomUUID;
}
async send(
envelope: NotificationEnvelope,
signal?: AbortSignal,
): Promise<NotificationReceipt> {
if (envelope.workspaceId && envelope.workspaceId !== this.workspaceId) {
throw new NotificationDeliveryError('buzz_workspace_mismatch', false);
}
if (!isUuid(envelope.destination)) {
throw new NotificationDeliveryError('invalid_buzz_channel', false);
}
const createdAt = notificationTimestamp(envelope.notification);
if (this.authTag && !buzzAuthConditionsAllow(this.authTag[2]!, 9, createdAt)) {
throw new NotificationDeliveryError('buzz_auth_tag_conditions', false);
}
const event = finalizeEvent(
{
kind: 9,
created_at: createdAt,
tags: [
['h', envelope.destination],
[
'client',
'PIG',
createHash('sha256').update(envelope.idempotencyKey).digest('hex'),
],
...(this.authTag ? [this.authTag] : []),
],
content: formatBuzzNotification(envelope.notification),
},
this.secretKey,
);
const body = JSON.stringify(event);
const authorization = this.createNip98Authorization(body);
let response: Response;
try {
response = await this.fetchImpl(this.eventsUrl, {
method: 'POST',
headers: {
authorization,
'content-type': 'application/json',
...(this.authTagJson ? { 'x-auth-tag': this.authTagJson } : {}),
},
body,
signal,
});
} catch {
// The event id is content-addressed and remains stable on a retry, so an
// ambiguous network outcome cannot create a second Buzz message.
throw new NotificationDeliveryError('buzz_network_error', true);
}
if (response.status === 429) {
throw new NotificationDeliveryError(
'buzz_rate_limited',
true,
retryAfterMs(response.headers.get('retry-after')),
);
}
if (response.status === 408 || response.status >= 500) {
throw new NotificationDeliveryError('buzz_unavailable', true);
}
if (!response.ok) {
throw new NotificationDeliveryError(`buzz_http_${response.status}`, false);
}
let result: BuzzRelayResponse;
try {
result = (await response.json()) as BuzzRelayResponse;
} catch {
throw new NotificationDeliveryError('invalid_buzz_response', true);
}
if (result.accepted !== true || !isEventId(result.event_id)) {
throw new NotificationDeliveryError('buzz_rejected', false);
}
if (result.event_id !== event.id) {
throw new NotificationDeliveryError('buzz_event_id_mismatch', false);
}
return { externalId: event.id };
}
private createNip98Authorization(body: string): string {
const authEvent = finalizeEvent(
{
kind: 27235,
created_at: Math.floor(this.now().getTime() / 1_000),
tags: [
['u', this.eventsUrl],
['method', 'POST'],
['nonce', this.nonce()],
['payload', createHash('sha256').update(body).digest('hex')],
],
content: '',
},
this.secretKey,
);
return `Nostr ${Buffer.from(JSON.stringify(authEvent), 'utf8').toString('base64')}`;
}
}
export function normaliseBuzzRelayUrl(value: string): string {
const trimmed = value.trim().replace(/^wss:/i, 'https:').replace(/^ws:/i, 'http:');
let url: URL;
try {
url = new URL(trimmed);
} catch {
throw new Error('BUZZ_RELAY_URL must be a valid http(s) or ws(s) URL.');
}
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error('BUZZ_RELAY_URL must be a credential-free http(s) or ws(s) URL.');
}
url.pathname = url.pathname.replace(/\/+$/, '');
url.search = '';
url.hash = '';
return url.toString().replace(/\/$/, '');
}
export function parseBuzzPrivateKey(value: string): Uint8Array {
const trimmed = value.trim();
let key: Uint8Array;
try {
if (trimmed.startsWith('nsec1')) {
const decoded = nip19.decode(trimmed);
if (decoded.type !== 'nsec') throw new Error('wrong key type');
key = decoded.data;
} else {
if (!/^[a-f0-9]{64}$/i.test(trimmed)) throw new Error('invalid hex');
key = Uint8Array.from(Buffer.from(trimmed, 'hex'));
}
getPublicKey(key);
} catch {
throw new Error('BUZZ_PRIVATE_KEY must be a valid 32-byte hex or nsec private key.');
}
return key;
}
export function parseAndVerifyBuzzAuthTag(value: string, agentPublicKey: string): string[] {
let tag: unknown;
try {
tag = JSON.parse(value);
} catch {
throw new Error('BUZZ_AUTH_TAG must be a valid JSON array.');
}
if (
!Array.isArray(tag) ||
tag.length !== 4 ||
tag.some((part) => typeof part !== 'string') ||
tag[0] !== 'auth' ||
!/^[a-f0-9]{64}$/.test(tag[1] as string) ||
!/^[a-f0-9]{128}$/.test(tag[3] as string)
) {
throw new Error('BUZZ_AUTH_TAG has an invalid NIP-OA structure.');
}
const parts = tag as string[];
validateBuzzAuthConditions(parts[2]!);
if (parts[1] === agentPublicKey) {
throw new Error('BUZZ_AUTH_TAG must be signed by an owner distinct from the agent.');
}
const digest = createHash('sha256')
.update(`nostr:agent-auth:${agentPublicKey}:${parts[2]}`)
.digest();
let verified = false;
try {
verified = schnorr.verify(hexBytes(parts[3]!), digest, hexBytes(parts[1]!));
} catch {
verified = false;
}
if (!verified) throw new Error('BUZZ_AUTH_TAG signature does not authorize this agent key.');
return parts;
}
function notificationTimestamp(notification: Notification): number {
const value = notification.kind === 'stage_change' ? notification.changedAt : notification.observedAt;
const milliseconds = Date.parse(value);
if (!Number.isFinite(milliseconds)) {
throw new NotificationDeliveryError('invalid_notification_timestamp', false);
}
return Math.floor(milliseconds / 1_000);
}
function formatBuzzNotification(notification: Notification): string {
if (notification.kind === 'stage_change') {
return [
`**${notification.dealName}** moved from \`${notification.fromStage}\` to \`${notification.toStage}\`.`,
`${notification.dealSide === 'demand' ? 'Demand' : 'Supply'} pipeline stage changed in PIG.`,
].join('\n');
}
return [
`**Idle capacity: ${notification.commitmentName}**`,
`${notification.gpuType} has ${formatNumber(notification.idleGpuHours)} unsold GPU-hours (${Math.round(notification.utilisation * 100)}% utilised).`,
`Idle committed cost: ${formatMoney(notification.idleCostCents)}.`,
].join('\n');
}
function formatNumber(value: number): string {
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }).format(value);
}
function formatMoney(cents: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(cents / 100);
}
function validateBuzzAuthConditions(conditions: string): void {
if (conditions === '') return;
for (const clause of conditions.split('&')) {
const match = /^(kind=|created_at<|created_at>)(0|[1-9]\d*)$/.exec(clause);
if (!match) throw new Error('BUZZ_AUTH_TAG contains invalid NIP-OA conditions.');
const value = Number(match[2]);
const maximum = match[1] === 'kind=' ? 65_535 : 4_294_967_295;
if (!Number.isSafeInteger(value) || value > maximum) {
throw new Error('BUZZ_AUTH_TAG contains out-of-range NIP-OA conditions.');
}
}
}
function buzzAuthConditionsAllow(conditions: string, kind: number, createdAt: number): boolean {
if (conditions === '') return true;
return conditions.split('&').every((clause) => {
if (clause.startsWith('kind=')) return kind === Number(clause.slice(5));
if (clause.startsWith('created_at<')) return createdAt < Number(clause.slice(11));
if (clause.startsWith('created_at>')) return createdAt > Number(clause.slice(11));
return false;
});
}
function retryAfterMs(value: string | null): number | undefined {
const seconds = Number(value);
return Number.isFinite(seconds) && seconds > 0 ? Math.min(seconds, 3_600) * 1_000 : undefined;
}
function hexBytes(value: string): Uint8Array {
return Uint8Array.from(Buffer.from(value, 'hex'));
}
function isUuid(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
}
function isEventId(value: string | undefined): value is string {
return Boolean(value && /^[a-f0-9]{64}$/.test(value));
}
export function decodeBuzzAuthorization(value: string): Event | null {
if (!value.startsWith('Nostr ')) return null;
try {
const event = JSON.parse(Buffer.from(value.slice(6), 'base64').toString('utf8')) as Event;
return verifyEvent(event) ? event : null;
} catch {
return null;
}
}
+672
View File
@@ -0,0 +1,672 @@
import {
ALLOCATION_STATUSES,
CONSUMING_ALLOCATION_STATUSES,
RESERVING_ALLOCATION_STATUSES,
} from '@pig/core';
import type {
AllocationStatus,
GuaranteeType,
GpuSocket,
InterconnectType,
SecurityTier,
} from '@pig/core';
import type {
Allocation,
CapacityCommitment,
Database,
DemandDeal,
NewCapacityCommitment,
} from '@pig/db';
import { allocations, capacityCommitments, demandDeals } from '@pig/db';
import { eq } from 'drizzle-orm';
import type { Principal } from '../lib/auth';
import { MutationError } from '../lib/mutation';
import { quantityAt, type CommitmentShape } from './capacity';
export type CapacityWriteTransaction = Parameters<Parameters<Database['transaction']>[0]>[0];
export interface CommitmentCapacity {
id: string;
gpuCount: number;
startsAt: Date;
endsAt: Date;
totalGpuHours: number;
shape: CommitmentShape | null;
oversubscriptionPct: number;
terminatedAt: Date | null;
}
export interface ReservationCapacity {
id?: string;
gpuHours: number;
startsAt: Date;
endsAt: Date;
status: AllocationStatus;
holdExpiresAt: Date | null;
}
export interface CapacityViolation {
code: 'outside_commitment_window' | 'total_capacity_exceeded' | 'shape_capacity_exceeded';
message: string;
at?: Date;
reserved?: number;
available?: number;
}
const EPSILON = 1e-7;
function isLiveReservation(reservation: ReservationCapacity, now: Date): boolean {
if (!(RESERVING_ALLOCATION_STATUSES as readonly string[]).includes(reservation.status)) {
return false;
}
return !(
reservation.status === 'planned' &&
reservation.holdExpiresAt !== null &&
reservation.holdExpiresAt <= now
);
}
function reservationRate(reservation: ReservationCapacity): number {
const durationHours =
(reservation.endsAt.getTime() - reservation.startsAt.getTime()) / 3_600_000;
return durationHours > 0 ? reservation.gpuHours / durationHours : Number.POSITIVE_INFINITY;
}
/**
* Checks both the contracted hour budget and every instantaneous shape interval.
* The row lock used by the caller turns this pure check into a concurrency-safe
* invariant rather than an optimistic preflight that two requests can both pass.
*/
export function findCapacityViolation(
commitment: CommitmentCapacity,
existing: readonly ReservationCapacity[],
candidate: ReservationCapacity | null,
now: Date,
): CapacityViolation | null {
const reservations = [...existing, ...(candidate ? [candidate] : [])].filter((reservation) =>
isLiveReservation(reservation, now),
);
for (const reservation of reservations) {
if (
reservation.startsAt < commitment.startsAt ||
reservation.endsAt > commitment.endsAt ||
reservation.endsAt <= reservation.startsAt
) {
return {
code: 'outside_commitment_window',
message: 'The allocation window must sit inside the commitment window.',
};
}
}
const multiplier = 1 + commitment.oversubscriptionPct / 100;
const allowedGpuHours = commitment.totalGpuHours * multiplier;
const reservedGpuHours = reservations.reduce((sum, reservation) => sum + reservation.gpuHours, 0);
if (reservedGpuHours - allowedGpuHours > EPSILON) {
return {
code: 'total_capacity_exceeded',
message: 'The allocation would exceed the commitment GPU-hour budget.',
reserved: reservedGpuHours,
available: allowedGpuHours,
};
}
const boundaries = new Set<number>([
commitment.startsAt.getTime(),
commitment.endsAt.getTime(),
...(commitment.shape?.intervals.map((boundary) => Date.parse(boundary)) ?? []),
]);
for (const reservation of reservations) {
boundaries.add(reservation.startsAt.getTime());
boundaries.add(reservation.endsAt.getTime());
}
const ordered = [...boundaries]
.filter(
(boundary) =>
Number.isFinite(boundary) &&
boundary >= commitment.startsAt.getTime() &&
boundary <= commitment.endsAt.getTime(),
)
.sort((a, b) => a - b);
for (let index = 0; index < ordered.length - 1; index++) {
const intervalStart = ordered[index]!;
const intervalEnd = ordered[index + 1]!;
if (intervalEnd <= intervalStart) continue;
const overlapping = reservations.filter(
(reservation) =>
reservation.startsAt.getTime() < intervalEnd &&
reservation.endsAt.getTime() > intervalStart,
);
if (overlapping.length === 0) continue;
const at = new Date(intervalStart + (intervalEnd - intervalStart) / 2);
const reserved = overlapping.reduce(
(sum, reservation) => sum + reservationRate(reservation),
0,
);
const available = quantityAt(commitment.shape, commitment.gpuCount, at) * multiplier;
if (reserved - available > EPSILON) {
return {
code: 'shape_capacity_exceeded',
message: 'The allocation would exceed available GPUs during part of its window.',
at,
reserved,
available,
};
}
}
return null;
}
export function commitmentCapacityError(commitment: CommitmentCapacity): string | null {
const durationHours =
(commitment.endsAt.getTime() - commitment.startsAt.getTime()) / 3_600_000;
if (durationHours <= 0) return 'Commitment end must be after its start.';
if (commitment.oversubscriptionPct < 0) return 'Oversubscription cannot be negative.';
const shape = commitment.shape;
if (!shape) {
if (commitment.totalGpuHours - durationHours * commitment.gpuCount > EPSILON) {
return 'Total GPU-hours cannot exceed the flat commitment envelope.';
}
return null;
}
if (shape.intervals.length < 2 || shape.quantities.length !== shape.intervals.length - 1) {
return 'Shape quantities must have exactly one entry per interval.';
}
const boundaries = shape.intervals.map((boundary) => Date.parse(boundary));
if (boundaries.some((boundary) => !Number.isFinite(boundary))) {
return 'Shape intervals must be valid ISO-8601 timestamps.';
}
if (
boundaries[0] !== commitment.startsAt.getTime() ||
boundaries[boundaries.length - 1] !== commitment.endsAt.getTime()
) {
return 'Shape intervals must cover the full commitment window.';
}
for (let index = 0; index < boundaries.length - 1; index++) {
if (boundaries[index + 1]! <= boundaries[index]!) {
return 'Shape intervals must be strictly ascending.';
}
}
if (
shape.quantities.some(
(quantity) => !Number.isInteger(quantity) || quantity < 0 || quantity > commitment.gpuCount,
)
) {
return 'Shape quantities must be whole GPUs within the commitment envelope.';
}
const shapedGpuHours = shape.quantities.reduce(
(sum, quantity, index) =>
sum + ((boundaries[index + 1]! - boundaries[index]!) / 3_600_000) * quantity,
0,
);
if (commitment.totalGpuHours - shapedGpuHours > EPSILON) {
return 'Total GPU-hours cannot exceed the capacity described by the shape.';
}
return null;
}
export interface CommitmentWriteInput {
accountId: string;
siteId?: string | null;
supplyDealId?: string | null;
name: string;
gpuType: string;
socket?: GpuSocket | null;
gpuCount: number;
interconnectType?: InterconnectType;
securityTier?: SecurityTier;
startsAt: string;
endsAt: string;
totalGpuHours: number;
costPerGpuHourCents: number;
currency?: string;
shape?: CommitmentShape | null;
colocateWith?: string[];
isContiguous?: boolean;
minimumSpendCents?: number | null;
isAutoRenew?: boolean;
noticeDays?: number | null;
takeOrPayFloorPct?: number | null;
prepaidPct?: number | null;
prepaidAmountCents?: number | null;
usefulLifeYears?: number | null;
salvageValuePct?: number | null;
depreciationStartAt?: string | null;
costOfCapitalBps?: number | null;
financingInstrument?: string | null;
oversubscriptionPct?: number;
notes?: string | null;
terminatedAt?: string | null;
}
export interface AllocationWriteInput {
capacityCommitmentId: string;
demandDealId: string;
gpuHours: number;
pricePerGpuHourCents: number;
currency?: string;
startsAt: string;
endsAt: string;
status: (typeof CONSUMING_ALLOCATION_STATUSES)[number];
guaranteeType?: GuaranteeType;
priority?: number;
complianceDecisionId?: string | null;
notes?: string | null;
}
export interface HoldWriteInput
extends Omit<AllocationWriteInput, 'status' | 'pricePerGpuHourCents'> {
pricePerGpuHourCents?: number;
holdExpiresAt: string;
holdOpportunityCostCents?: number | null;
}
export interface ReleaseWriteInput {
reason?: string;
}
export interface CommitmentWriteResult {
commitment: CapacityCommitment;
}
export interface AllocationWriteResult {
allocation: Allocation;
commitment: Pick<CapacityCommitment, 'id' | 'name'>;
deal: Pick<DemandDeal, 'id' | 'accountId'>;
}
export interface ReleaseWriteResult {
allocation: Allocation;
accountId?: string;
previousStatus: AllocationStatus;
}
function toCapacity(commitment: CapacityCommitment): CommitmentCapacity {
return {
id: commitment.id,
gpuCount: commitment.gpuCount,
startsAt: commitment.startsAt,
endsAt: commitment.endsAt,
totalGpuHours: Number(commitment.totalGpuHours),
shape: commitment.shape,
oversubscriptionPct: Number(commitment.oversubscriptionPct),
terminatedAt: commitment.terminatedAt,
};
}
function toReservation(allocation: Allocation): ReservationCapacity {
return {
id: allocation.id,
gpuHours: Number(allocation.gpuHours),
startsAt: allocation.startsAt,
endsAt: allocation.endsAt,
status: allocation.status,
holdExpiresAt: allocation.holdExpiresAt,
};
}
function assertCommitmentValid(commitment: CommitmentCapacity): void {
const error = commitmentCapacityError(commitment);
if (error) throw new MutationError('invalid_commitment', error, 400);
}
function assertCapacityAvailable(
commitment: CommitmentCapacity,
existing: readonly Allocation[],
candidate: ReservationCapacity | null,
now: Date,
): void {
const violation = findCapacityViolation(
commitment,
existing.map(toReservation),
candidate,
now,
);
if (!violation) return;
throw new MutationError(violation.code, violation.message, 409);
}
export class CapacityWriteService {
constructor(private readonly tx: CapacityWriteTransaction) {}
private async lockCommitment(id: string): Promise<CapacityCommitment> {
const [commitment] = await this.tx
.select()
.from(capacityCommitments)
.where(eq(capacityCommitments.id, id))
.limit(1)
.for('update');
if (!commitment) throw MutationError.notFound('Capacity commitment');
return commitment;
}
private async reservations(commitmentId: string): Promise<Allocation[]> {
return this.tx
.select()
.from(allocations)
.where(eq(allocations.capacityCommitmentId, commitmentId));
}
private async demandDeal(id: string): Promise<Pick<DemandDeal, 'id' | 'accountId'>> {
const [deal] = await this.tx
.select({ id: demandDeals.id, accountId: demandDeals.accountId })
.from(demandDeals)
.where(eq(demandDeals.id, id))
.limit(1);
if (!deal) throw MutationError.notFound('Demand deal');
return deal;
}
async createCommitment(input: CommitmentWriteInput, now: Date): Promise<CommitmentWriteResult> {
const capacity: CommitmentCapacity = {
id: 'new',
gpuCount: input.gpuCount,
startsAt: new Date(input.startsAt),
endsAt: new Date(input.endsAt),
totalGpuHours: input.totalGpuHours,
shape: input.shape ?? null,
oversubscriptionPct: input.oversubscriptionPct ?? 0,
terminatedAt: null,
};
assertCommitmentValid(capacity);
const [commitment] = await this.tx
.insert(capacityCommitments)
.values({
accountId: input.accountId,
siteId: input.siteId,
supplyDealId: input.supplyDealId,
name: input.name,
gpuType: input.gpuType,
socket: input.socket,
gpuCount: input.gpuCount,
interconnectType: input.interconnectType,
securityTier: input.securityTier,
startsAt: capacity.startsAt,
endsAt: capacity.endsAt,
totalGpuHours: String(input.totalGpuHours),
costPerGpuHourCents: input.costPerGpuHourCents,
currency: input.currency,
shape: input.shape,
colocateWith: input.colocateWith,
isContiguous: input.isContiguous,
minimumSpendCents: input.minimumSpendCents,
isAutoRenew: input.isAutoRenew,
noticeDays: input.noticeDays,
takeOrPayFloorPct:
input.takeOrPayFloorPct === undefined || input.takeOrPayFloorPct === null
? input.takeOrPayFloorPct
: String(input.takeOrPayFloorPct),
prepaidPct:
input.prepaidPct === undefined || input.prepaidPct === null
? input.prepaidPct
: String(input.prepaidPct),
prepaidAmountCents: input.prepaidAmountCents,
usefulLifeYears:
input.usefulLifeYears === undefined || input.usefulLifeYears === null
? input.usefulLifeYears
: String(input.usefulLifeYears),
salvageValuePct:
input.salvageValuePct === undefined || input.salvageValuePct === null
? input.salvageValuePct
: String(input.salvageValuePct),
depreciationStartAt:
input.depreciationStartAt === undefined
? undefined
: input.depreciationStartAt === null
? null
: new Date(input.depreciationStartAt),
costOfCapitalBps: input.costOfCapitalBps,
financingInstrument: input.financingInstrument,
oversubscriptionPct: String(input.oversubscriptionPct ?? 0),
notes: input.notes,
createdAt: now,
updatedAt: now,
})
.returning();
if (!commitment) throw new MutationError('write_failed', 'Commitment was not created.', 409);
return { commitment };
}
async updateCommitment(
id: string,
input: Partial<CommitmentWriteInput>,
now: Date,
): Promise<CommitmentWriteResult> {
const current = await this.lockCommitment(id);
const existing = await this.reservations(id);
const prospective: CommitmentCapacity = {
id,
gpuCount: input.gpuCount ?? current.gpuCount,
startsAt: input.startsAt ? new Date(input.startsAt) : current.startsAt,
endsAt: input.endsAt ? new Date(input.endsAt) : current.endsAt,
totalGpuHours: input.totalGpuHours ?? Number(current.totalGpuHours),
shape: input.shape === undefined ? current.shape : input.shape,
oversubscriptionPct:
input.oversubscriptionPct ?? Number(current.oversubscriptionPct),
terminatedAt:
input.terminatedAt === undefined
? current.terminatedAt
: input.terminatedAt
? new Date(input.terminatedAt)
: null,
};
assertCommitmentValid(prospective);
assertCapacityAvailable(prospective, existing, null, now);
if (
prospective.terminatedAt &&
existing.some((allocation) => isLiveReservation(toReservation(allocation), now))
) {
throw new MutationError(
'commitment_in_use',
'Release live allocations before terminating the commitment.',
409,
);
}
const changes: Partial<NewCapacityCommitment> = { updatedAt: now };
const assign = <Key extends keyof CommitmentWriteInput>(
key: Key,
value: NewCapacityCommitment[keyof NewCapacityCommitment],
) => {
if (input[key] !== undefined) {
(changes as Record<string, unknown>)[key] = value;
}
};
assign('accountId', input.accountId);
assign('siteId', input.siteId);
assign('supplyDealId', input.supplyDealId);
assign('name', input.name);
assign('gpuType', input.gpuType);
assign('socket', input.socket);
assign('gpuCount', input.gpuCount);
assign('interconnectType', input.interconnectType);
assign('securityTier', input.securityTier);
assign('startsAt', prospective.startsAt);
assign('endsAt', prospective.endsAt);
assign('totalGpuHours', String(prospective.totalGpuHours));
assign('costPerGpuHourCents', input.costPerGpuHourCents);
assign('currency', input.currency);
assign('shape', input.shape);
assign('colocateWith', input.colocateWith);
assign('isContiguous', input.isContiguous);
assign('minimumSpendCents', input.minimumSpendCents);
assign('isAutoRenew', input.isAutoRenew);
assign('noticeDays', input.noticeDays);
assign(
'takeOrPayFloorPct',
input.takeOrPayFloorPct === null ? null : String(input.takeOrPayFloorPct),
);
assign('prepaidPct', input.prepaidPct === null ? null : String(input.prepaidPct));
assign('prepaidAmountCents', input.prepaidAmountCents);
assign('usefulLifeYears', input.usefulLifeYears === null ? null : String(input.usefulLifeYears));
assign('salvageValuePct', input.salvageValuePct === null ? null : String(input.salvageValuePct));
assign(
'depreciationStartAt',
input.depreciationStartAt ? new Date(input.depreciationStartAt) : null,
);
assign('costOfCapitalBps', input.costOfCapitalBps);
assign('financingInstrument', input.financingInstrument);
assign('oversubscriptionPct', String(prospective.oversubscriptionPct));
assign('notes', input.notes);
assign('terminatedAt', prospective.terminatedAt);
const [commitment] = await this.tx
.update(capacityCommitments)
.set(changes)
.where(eq(capacityCommitments.id, id))
.returning();
if (!commitment) throw MutationError.notFound('Capacity commitment');
return { commitment };
}
async createAllocation(
input: AllocationWriteInput,
principal: Principal,
now: Date,
): Promise<AllocationWriteResult> {
const commitment = await this.lockCommitment(input.capacityCommitmentId);
if (commitment.terminatedAt) {
throw new MutationError('commitment_terminated', 'The commitment is terminated.', 409);
}
const deal = await this.demandDeal(input.demandDealId);
const existing = await this.reservations(commitment.id);
const candidate: ReservationCapacity = {
gpuHours: input.gpuHours,
startsAt: new Date(input.startsAt),
endsAt: new Date(input.endsAt),
status: input.status,
holdExpiresAt: null,
};
assertCapacityAvailable(toCapacity(commitment), existing, candidate, now);
const [allocation] = await this.tx
.insert(allocations)
.values({
capacityCommitmentId: commitment.id,
demandDealId: deal.id,
gpuHours: String(input.gpuHours),
pricePerGpuHourCents: input.pricePerGpuHourCents,
currency: input.currency,
startsAt: candidate.startsAt,
endsAt: candidate.endsAt,
status: input.status,
guaranteeType: input.guaranteeType,
priority: input.priority,
complianceDecisionId: input.complianceDecisionId,
createdByUserId: principal.userId,
notes: input.notes,
createdAt: now,
updatedAt: now,
})
.returning();
if (!allocation) throw new MutationError('write_failed', 'Allocation was not created.', 409);
return { allocation, commitment, deal };
}
async createHold(
input: HoldWriteInput,
principal: Principal,
now: Date,
): Promise<AllocationWriteResult> {
const holdExpiresAt = new Date(input.holdExpiresAt);
if (holdExpiresAt <= now) {
throw new MutationError('hold_already_expired', 'A new hold must expire in the future.', 400);
}
const commitment = await this.lockCommitment(input.capacityCommitmentId);
if (commitment.terminatedAt) {
throw new MutationError('commitment_terminated', 'The commitment is terminated.', 409);
}
const deal = await this.demandDeal(input.demandDealId);
const existing = await this.reservations(commitment.id);
const candidate: ReservationCapacity = {
gpuHours: input.gpuHours,
startsAt: new Date(input.startsAt),
endsAt: new Date(input.endsAt),
status: 'planned',
holdExpiresAt,
};
assertCapacityAvailable(toCapacity(commitment), existing, candidate, now);
const [allocation] = await this.tx
.insert(allocations)
.values({
capacityCommitmentId: commitment.id,
demandDealId: deal.id,
gpuHours: String(input.gpuHours),
pricePerGpuHourCents: input.pricePerGpuHourCents ?? 0,
currency: input.currency,
startsAt: candidate.startsAt,
endsAt: candidate.endsAt,
status: 'planned',
holdExpiresAt,
holdOpportunityCostCents: input.holdOpportunityCostCents,
guaranteeType: input.guaranteeType,
priority: input.priority,
complianceDecisionId: input.complianceDecisionId,
createdByUserId: principal.userId,
notes: input.notes,
createdAt: now,
updatedAt: now,
})
.returning();
if (!allocation) throw new MutationError('write_failed', 'Hold was not created.', 409);
return { allocation, commitment, deal };
}
async releaseAllocation(
id: string,
_input: ReleaseWriteInput,
now: Date,
): Promise<ReleaseWriteResult> {
const [reference] = await this.tx
.select({ capacityCommitmentId: allocations.capacityCommitmentId })
.from(allocations)
.where(eq(allocations.id, id))
.limit(1);
if (!reference) throw MutationError.notFound('Allocation');
await this.lockCommitment(reference.capacityCommitmentId);
const [current] = await this.tx
.select()
.from(allocations)
.where(eq(allocations.id, id))
.limit(1)
.for('update');
if (!current) throw MutationError.notFound('Allocation');
if (current.status === 'released') {
throw new MutationError('already_released', 'The allocation is already released.', 409);
}
if (current.status === 'completed') {
throw new MutationError(
'completed_allocation',
'Completed billable usage cannot be released.',
409,
);
}
const [allocation] = await this.tx
.update(allocations)
.set({ status: 'released', releasedAt: now, holdExpiresAt: null, updatedAt: now })
.where(eq(allocations.id, id))
.returning();
if (!allocation) throw MutationError.notFound('Allocation');
const deal = current.demandDealId
? await this.demandDeal(current.demandDealId)
: undefined;
return { allocation, accountId: deal?.accountId, previousStatus: current.status };
}
}
export const VALID_ALLOCATION_STATUSES = ALLOCATION_STATUSES;
+61 -32
View File
@@ -13,12 +13,18 @@ import type { Database } from '@pig/db';
import {
allocations,
capacityCommitments,
CONSUMING_ALLOCATION_STATUSES,
RESERVING_ALLOCATION_STATUSES,
complianceDecisionAllowsMatch,
inventoryListings,
} from '@pig/db';
import { computeMargin, breakEvenPricePerGpuHourCents } from '@pig/core';
import {
computeMargin,
breakEvenPricePerGpuHourCents,
CONSUMING_ALLOCATION_STATUSES,
RESERVING_ALLOCATION_STATUSES,
securityTierSatisfies,
} from '@pig/core';
import type { InterconnectType, SecurityTier } from '@pig/core';
import type { ComplianceMatchDecision } from '@pig/db';
export interface CommitmentShape {
intervals: string[];
@@ -71,6 +77,7 @@ export function gpuHoursFromShape(shape: CommitmentShape): number {
export interface AvailabilityRow {
commitmentId: string;
accountId: string;
name: string;
gpuType: string;
gpuCount: number;
@@ -91,6 +98,48 @@ export interface AvailabilityRow {
breakEvenPriceCents: number | null;
}
export interface CapacityMatchRequirement {
gpuType?: string;
gpuTypeAlternatives?: string[];
gpuCount: number;
totalGpuHours?: number;
requiresHighSpeedInterconnect?: boolean;
minSecurityTier?: SecurityTier;
startsAt?: Date;
endsAt?: Date;
maxPricePerGpuHourCents?: number;
/** `null` means evaluated but missing, and therefore blocks the match. */
complianceDecision?: ComplianceMatchDecision | null;
}
export function capacityMeetsRequirement(
row: AvailabilityRow,
requirement: CapacityMatchRequirement,
): boolean {
const acceptableTypes = [
...(requirement.gpuType ? [requirement.gpuType] : []),
...(requirement.gpuTypeAlternatives ?? []),
];
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 &&
!securityTierSatisfies(row.securityTier, requirement.minSecurityTier)
) {
return false;
}
if (!complianceDecisionAllowsMatch(requirement.complianceDecision)) 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;
}
export class CapacityService {
constructor(private readonly db: Database) {}
@@ -161,6 +210,7 @@ export class CapacityService {
return {
commitmentId: commitment.id,
accountId: commitment.accountId,
name: commitment.name,
gpuType: commitment.gpuType,
gpuCount: commitment.gpuCount,
@@ -198,17 +248,7 @@ export class CapacityService {
* 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<
async match(requirement: CapacityMatchRequirement): Promise<
(AvailabilityRow & {
/** 01. Higher is a better fit. */
score: number;
@@ -224,24 +264,7 @@ export class CapacityService {
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;
})
.filter((row) => capacityMeetsRequirement(row, requirement))
.map((row) => {
const rationale: string[] = [];
let score = 0.5;
@@ -268,6 +291,12 @@ export class CapacityService {
rationale.push(`${row.interconnectType} fabric meets the training requirement.`);
}
if (requirement.minSecurityTier) {
rationale.push(
`${row.securityTier} capacity meets the ${requirement.minSecurityTier} security requirement.`,
);
}
// Margin headroom: can this be sold above break-even, within the
// customer's ceiling?
if (requirement.maxPricePerGpuHourCents && row.breakEvenPriceCents != null) {
+207
View File
@@ -0,0 +1,207 @@
import { asc, desc, eq, inArray } from 'drizzle-orm';
import type { Contract, Database, SlaTerm } from '@pig/db';
import {
accounts,
contractObligations,
contracts,
slaMetricTargets,
slaTerms,
} from '@pig/db';
export interface EffectiveTerm<Value = unknown> {
value: Value;
sourceContractId: string;
inherited: boolean;
}
export type EffectiveTerms = Record<string, EffectiveTerm>;
const CONTRACT_TERM_FIELDS = [
'contractingPartyName',
'takeOrPayFloorPct',
'prepaidPct',
'terminationTier',
'assignableOnDefault',
'assignmentDeadlineBusinessDays',
'effectiveAt',
'expiresAt',
'isAutoRenew',
'noticeDays',
'valueCents',
'currency',
'governingLaw',
] as const satisfies readonly (keyof Contract)[];
const SLA_TERM_FIELDS = [
'kind',
'uptimeTargetPct',
'nodeReplacementHours',
'mttrHours',
'supportResponseHours',
'measurementWindow',
'measurementUnit',
'remedyType',
'abatementTriggerValue',
'abatementTriggerUnit',
'claimDeadlineValue',
'claimDeadlineUnit',
'creditExpiryMonths',
'isSoleRemedy',
'sparePoolObligation',
'sparePoolScope',
'maintenanceClasses',
'reasonableEndeavoursDaysPerYear',
'rcaDeliveryHours',
'creditSchedule',
'creditCapPct',
'exclusions',
] as const satisfies readonly (keyof SlaTerm)[];
/**
* Resolves explicit child terms before walking toward the master agreement.
* Null means "not negotiated here"; false, zero and empty arrays are explicit
* values and therefore must not accidentally fall through to a parent.
*/
export function resolveContractPrecedence(
selectedId: string,
contractRows: readonly Contract[],
slaRows: readonly SlaTerm[],
): { chain: Contract[]; contract: EffectiveTerms; sla: EffectiveTerms } {
const byId = new Map(contractRows.map((row) => [row.id, row]));
const slaByContract = new Map(slaRows.map((row) => [row.contractId, row]));
const chain: Contract[] = [];
const visited = new Set<string>();
let cursor = byId.get(selectedId);
while (cursor && !visited.has(cursor.id)) {
chain.push(cursor);
visited.add(cursor.id);
cursor = cursor.parentContractId ? byId.get(cursor.parentContractId) : undefined;
}
const contract: EffectiveTerms = {};
const sla: EffectiveTerms = {};
for (const [index, row] of chain.entries()) {
for (const field of CONTRACT_TERM_FIELDS) {
const value = row[field];
if (!(field in contract) && value !== null && value !== undefined) {
contract[field] = { value, sourceContractId: row.id, inherited: index > 0 };
}
}
const serviceLevel = slaByContract.get(row.id);
if (!serviceLevel) continue;
for (const field of SLA_TERM_FIELDS) {
const value = serviceLevel[field];
if (!(field in sla) && value !== null && value !== undefined) {
sla[field] = { value, sourceContractId: row.id, inherited: index > 0 };
}
}
}
return { chain, contract, sla };
}
export type RenewalState = 'not_applicable' | 'scheduled' | 'due' | 'expired';
export function renewalAlarm(
contract: Pick<Contract, 'expiresAt' | 'isAutoRenew' | 'noticeDays'>,
now = new Date(),
): { renewalNoticeAt: Date | null; renewalState: RenewalState } {
if (!contract.isAutoRenew || !contract.expiresAt || contract.noticeDays == null) {
return { renewalNoticeAt: null, renewalState: 'not_applicable' };
}
const renewalNoticeAt = new Date(
contract.expiresAt.getTime() - contract.noticeDays * 24 * 60 * 60 * 1000,
);
const renewalState =
contract.expiresAt <= now ? 'expired' : renewalNoticeAt <= now ? 'due' : 'scheduled';
return { renewalNoticeAt, renewalState };
}
export function validateParentRelationship(
child: Pick<Contract, 'id' | 'accountId' | 'side'>,
parent: Pick<Contract, 'id' | 'accountId' | 'side'>,
ancestors: readonly Pick<Contract, 'id'>[],
): string | null {
if (child.id === parent.id || ancestors.some((ancestor) => ancestor.id === child.id)) {
return 'A contract cannot be its own ancestor.';
}
if (child.accountId !== parent.accountId) {
return 'Parent and child contracts must belong to the same account.';
}
if (child.side !== parent.side) {
return 'Parent and child contracts must govern the same market side.';
}
return null;
}
export class ContractService {
constructor(private readonly db: Database) {}
async list() {
const rows = await this.db
.select({ contract: contracts, accountName: accounts.name })
.from(contracts)
.leftJoin(accounts, eq(accounts.id, contracts.accountId))
.orderBy(desc(contracts.updatedAt));
return rows.map((row) => ({
...row,
...renewalAlarm(row.contract),
}));
}
async detail(id: string) {
const [selected] = await this.db
.select({ contract: contracts, accountName: accounts.name })
.from(contracts)
.leftJoin(accounts, eq(accounts.id, contracts.accountId))
.where(eq(contracts.id, id))
.limit(1);
if (!selected) return null;
const accountContracts = await this.db
.select()
.from(contracts)
.where(eq(contracts.accountId, selected.contract.accountId))
.orderBy(asc(contracts.createdAt));
const contractIds = accountContracts.map((row) => row.id);
const serviceLevels = contractIds.length
? await this.db.select().from(slaTerms).where(inArray(slaTerms.contractId, contractIds))
: [];
const termIds = serviceLevels.map((row) => row.id);
const [metrics, obligations] = await Promise.all([
termIds.length
? this.db
.select()
.from(slaMetricTargets)
.where(inArray(slaMetricTargets.slaTermId, termIds))
: Promise.resolve([]),
this.db
.select()
.from(contractObligations)
.where(eq(contractObligations.contractId, id))
.orderBy(asc(contractObligations.dueAt)),
]);
const precedence = resolveContractPrecedence(id, accountContracts, serviceLevels);
const selectedSla = serviceLevels.find((row) => row.contractId === id) ?? null;
return {
...selected,
...renewalAlarm(selected.contract),
hierarchy: {
chain: precedence.chain,
children: accountContracts.filter((row) => row.parentContractId === id),
},
effectiveTerms: { contract: precedence.contract, sla: precedence.sla },
sla: selectedSla,
slaMetrics: selectedSla
? metrics.filter((row) => row.slaTermId === selectedSla.id)
: [],
obligations,
};
}
}
+551
View File
@@ -0,0 +1,551 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import type { Database, GoogleConnection, GoogleOauthFlow } from '@pig/db';
import { activities, googleConnections, googleOauthFlows } from '@pig/db';
import { and, eq, gt, isNull } from 'drizzle-orm';
import type { Principal } from '../lib/auth';
import { decryptSecret, encryptionReady, encryptSecret } from '../lib/secrets';
import { MutationError } from '../lib/mutation';
import {
MAX_IMPORT_CELL_CHARS,
MAX_IMPORT_COLUMNS,
MAX_IMPORT_ROWS,
normaliseTabularRows,
type ParsedTable,
} from './tabular-import';
export const GOOGLE_OAUTH_SCOPES = [
'https://www.googleapis.com/auth/drive.metadata.readonly',
'https://www.googleapis.com/auth/spreadsheets.readonly',
] as const;
const GOOGLE_AUTHORIZATION_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
const GOOGLE_TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
const GOOGLE_REVOCATION_ENDPOINT = 'https://oauth2.googleapis.com/revoke';
const GOOGLE_DRIVE_FILES_ENDPOINT = 'https://www.googleapis.com/drive/v3/files';
const GOOGLE_SHEETS_ENDPOINT = 'https://sheets.googleapis.com/v4/spreadsheets';
const OAUTH_FLOW_TTL_MS = 10 * 60 * 1_000;
const ACCESS_TOKEN_SKEW_MS = 60 * 1_000;
const DRIVE_PAGE_SIZE = 50;
const PKCE_PURPOSE = 'google-oauth:pkce-verifier';
const REFRESH_TOKEN_PURPOSE = 'google-oauth:refresh-token';
const ACCESS_TOKEN_PURPOSE = 'google-oauth:access-token';
export interface GoogleSheetsConfig {
clientId?: string;
clientSecret?: string;
redirectUri?: string;
encryptionKey?: string;
publicUrl: string;
}
export interface GoogleConnectionMetadata {
configured: boolean;
connected: boolean;
connectedAt: string | null;
scopes: string[];
}
export interface GoogleDriveFile {
id: string;
name: string;
modifiedTime: string | null;
}
export interface GoogleSheetMetadata {
sheetId: number;
title: string;
rowCount: number;
columnCount: number;
}
interface GoogleTokenResponse {
access_token?: string;
expires_in?: number;
refresh_token?: string;
scope?: string;
token_type?: string;
error?: string;
}
interface DriveListResponse {
files?: { id?: string; name?: string; modifiedTime?: string }[];
nextPageToken?: string;
incompleteSearch?: boolean;
}
interface SpreadsheetMetadataResponse {
properties?: { title?: string };
sheets?: {
properties?: {
sheetId?: number;
title?: string;
sheetType?: string;
hidden?: boolean;
gridProperties?: { rowCount?: number; columnCount?: number };
};
}[];
}
interface ValuesResponse {
values?: unknown[][];
}
export class GoogleApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly reconnectRequired = false,
) {
super(message);
this.name = 'GoogleApiError';
}
}
export class GoogleSheetsService {
constructor(
private readonly db: Database,
private readonly config: GoogleSheetsConfig,
private readonly fetchImpl: typeof fetch = fetch,
) {}
configured(): boolean {
return Boolean(
this.config.clientId &&
this.config.clientSecret &&
this.config.redirectUri &&
encryptionReady(this.config.encryptionKey),
);
}
async connectionMetadata(userId: string): Promise<GoogleConnectionMetadata> {
const [connection] = await this.db
.select()
.from(googleConnections)
.where(eq(googleConnections.userId, userId))
.limit(1);
return googleConnectionMetadata(this.configured(), connection);
}
async beginOAuth(principal: Principal, now = new Date()): Promise<{ authorizationUrl: string; browserBinding: string }> {
this.requireConfigured();
const state = randomBytes(32).toString('base64url');
const browserBinding = randomBytes(32).toString('base64url');
const verifier = randomBytes(32).toString('base64url');
const challenge = createHash('sha256').update(verifier).digest('base64url');
await this.db.insert(googleOauthFlows).values({
userId: principal.userId,
stateHash: oauthStateHash(state),
browserBindingHash: oauthStateHash(browserBinding),
pkceVerifierEncrypted: encryptSecret(verifier, this.config.encryptionKey, PKCE_PURPOSE),
expiresAt: new Date(now.getTime() + OAUTH_FLOW_TTL_MS),
createdAt: now,
});
return {
authorizationUrl: buildGoogleAuthorizationUrl({
clientId: this.config.clientId!,
redirectUri: this.config.redirectUri!,
state,
challenge,
}),
browserBinding,
};
}
async completeOAuth(input: { state: string; code: string; browserBinding: string }, now = new Date()): Promise<void> {
this.requireConfigured();
const [flow] = await this.db
.update(googleOauthFlows)
.set({ consumedAt: now })
.where(and(
eq(googleOauthFlows.stateHash, oauthStateHash(input.state)),
isNull(googleOauthFlows.consumedAt),
gt(googleOauthFlows.expiresAt, now),
))
.returning();
if (!flow || !oauthFlowMatches(flow, input.state, input.browserBinding, now)) {
throw new MutationError('invalid_oauth_state', 'The Google authorization request is invalid or expired.', 400);
}
const verifier = decryptSecret(
flow.pkceVerifierEncrypted,
this.config.encryptionKey,
PKCE_PURPOSE,
);
const token = await this.exchangeToken(new URLSearchParams({
client_id: this.config.clientId!,
client_secret: this.config.clientSecret!,
code: input.code,
code_verifier: verifier,
grant_type: 'authorization_code',
redirect_uri: this.config.redirectUri!,
}));
if (!token.access_token) {
throw new GoogleApiError('Google did not return an access token. Connect again.', 502, true);
}
const [existing] = await this.db
.select()
.from(googleConnections)
.where(eq(googleConnections.userId, flow.userId))
.limit(1);
const refreshTokenEncrypted = token.refresh_token
? encryptSecret(token.refresh_token, this.config.encryptionKey, REFRESH_TOKEN_PURPOSE)
: existing?.refreshTokenEncrypted;
if (!refreshTokenEncrypted) {
throw new GoogleApiError('Google did not grant offline access. Connect again and approve access.', 502, true);
}
const expiresAt = token.expires_in
? new Date(now.getTime() + token.expires_in * 1_000)
: null;
const scopes = token.scope?.split(/\s+/).filter(Boolean) ?? [...GOOGLE_OAUTH_SCOPES];
await this.db.transaction(async (tx) => {
await tx.insert(googleConnections).values({
userId: flow.userId,
refreshTokenEncrypted,
accessTokenEncrypted: encryptSecret(token.access_token!, this.config.encryptionKey, ACCESS_TOKEN_PURPOSE),
accessTokenExpiresAt: expiresAt,
scopes,
connectedAt: now,
updatedAt: now,
}).onConflictDoUpdate({
target: googleConnections.userId,
set: {
refreshTokenEncrypted,
accessTokenEncrypted: encryptSecret(token.access_token!, this.config.encryptionKey, ACCESS_TOKEN_PURPOSE),
accessTokenExpiresAt: expiresAt,
scopes,
connectedAt: now,
updatedAt: now,
},
});
await tx.insert(activities).values({
type: 'note',
subject: 'Connected Google Sheets import',
actorUserId: flow.userId,
source: 'manual',
occurredAt: now,
meta: { action: 'integration.connected', integration: 'google_sheets' },
});
});
}
async disconnect(principal: Principal, now = new Date()): Promise<void> {
const [connection] = await this.db
.select()
.from(googleConnections)
.where(eq(googleConnections.userId, principal.userId))
.limit(1);
if (!connection) return;
const token = decryptSecret(
connection.refreshTokenEncrypted,
this.config.encryptionKey,
REFRESH_TOKEN_PURPOSE,
);
const response = await this.fetchImpl(GOOGLE_REVOCATION_ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ token }),
});
if (!response.ok && response.status !== 400) {
throw new GoogleApiError('Google access could not be revoked. Try again.', response.status);
}
await this.db.transaction(async (tx) => {
await tx.delete(googleConnections).where(eq(googleConnections.userId, principal.userId));
await tx.insert(activities).values({
type: 'note',
subject: 'Disconnected Google Sheets import',
actorUserId: principal.userId,
source: 'manual',
occurredAt: now,
meta: { action: 'integration.disconnected', integration: 'google_sheets' },
});
});
}
async listSpreadsheets(
userId: string,
options: { pageToken?: string; search?: string },
): Promise<{ files: GoogleDriveFile[]; nextPageToken: string | null; incomplete: boolean }> {
const token = await this.accessToken(userId);
const query = [
"mimeType='application/vnd.google-apps.spreadsheet'",
'trashed=false',
...(options.search?.trim()
? [`name contains '${escapeDriveQuery(options.search.trim().slice(0, 100))}'`]
: []),
].join(' and ');
const url = new URL(GOOGLE_DRIVE_FILES_ENDPOINT);
url.searchParams.set('q', query);
url.searchParams.set('spaces', 'drive');
url.searchParams.set('orderBy', 'modifiedTime desc,name_natural');
url.searchParams.set('pageSize', String(DRIVE_PAGE_SIZE));
url.searchParams.set('fields', 'files(id,name,modifiedTime),nextPageToken,incompleteSearch');
url.searchParams.set('supportsAllDrives', 'true');
url.searchParams.set('includeItemsFromAllDrives', 'true');
if (options.pageToken) url.searchParams.set('pageToken', options.pageToken);
const body = await this.googleJson<DriveListResponse>(url, token, 'Google Drive could not list spreadsheets.');
return {
files: (body.files ?? []).flatMap((file) => file.id && file.name
? [{ id: file.id, name: file.name, modifiedTime: file.modifiedTime ?? null }]
: []),
nextPageToken: body.nextPageToken ?? null,
incomplete: body.incompleteSearch === true,
};
}
async spreadsheetMetadata(
userId: string,
spreadsheetId: string,
): Promise<{ title: string; sheets: GoogleSheetMetadata[] }> {
const token = await this.accessToken(userId);
const url = new URL(`${GOOGLE_SHEETS_ENDPOINT}/${encodeURIComponent(spreadsheetId)}`);
url.searchParams.set(
'fields',
'properties(title),sheets(properties(sheetId,title,index,sheetType,hidden,gridProperties(rowCount,columnCount)))',
);
const body = await this.googleJson<SpreadsheetMetadataResponse>(url, token, 'Google Sheets could not read spreadsheet metadata.');
const sheets = (body.sheets ?? []).flatMap((sheet) => {
const properties = sheet.properties;
if (
!properties ||
properties.sheetType !== 'GRID' ||
properties.hidden ||
properties.sheetId == null ||
!properties.title
) return [];
return [{
sheetId: properties.sheetId,
title: properties.title,
rowCount: properties.gridProperties?.rowCount ?? 0,
columnCount: properties.gridProperties?.columnCount ?? 0,
}];
});
return { title: body.properties?.title ?? 'Google spreadsheet', sheets };
}
async readTable(
userId: string,
input: { spreadsheetId: string; sheetId: number; range: string },
): Promise<ParsedTable> {
const metadata = await this.spreadsheetMetadata(userId, input.spreadsheetId);
const sheet = metadata.sheets.find((candidate) => candidate.sheetId === input.sheetId);
if (!sheet) throw new MutationError('google_sheet_not_found', 'The selected visible grid sheet no longer exists.', 404);
const bounded = parseBoundedGoogleRange(input.range, {
rowCount: sheet.rowCount,
columnCount: sheet.columnCount,
});
const a1 = `'${sheet.title.replace(/'/g, "''")}'!${bounded.a1}`;
const token = await this.accessToken(userId);
const url = new URL(
`${GOOGLE_SHEETS_ENDPOINT}/${encodeURIComponent(input.spreadsheetId)}/values/${encodeURIComponent(a1)}`,
);
url.searchParams.set('majorDimension', 'ROWS');
// Formatted values expose cached/display results, never executable formula source.
url.searchParams.set('valueRenderOption', 'FORMATTED_VALUE');
const body = await this.googleJson<ValuesResponse>(url, token, 'Google Sheets could not read the selected range.');
const table = normaliseGoogleValues(body.values ?? [], bounded.columns);
return {
fileName: `Google Sheets · ${metadata.title} · ${sheet.title} · ${bounded.a1}`,
sheetName: sheet.title,
...table,
};
}
private async accessToken(userId: string, now = new Date()): Promise<string> {
const [connection] = await this.db
.select()
.from(googleConnections)
.where(eq(googleConnections.userId, userId))
.limit(1);
if (!connection) throw new GoogleApiError('Connect Google Sheets before selecting a spreadsheet.', 409, true);
if (
connection.accessTokenEncrypted &&
connection.accessTokenExpiresAt &&
connection.accessTokenExpiresAt.getTime() > now.getTime() + ACCESS_TOKEN_SKEW_MS
) {
return decryptSecret(connection.accessTokenEncrypted, this.config.encryptionKey, ACCESS_TOKEN_PURPOSE);
}
this.requireConfigured();
const refreshToken = decryptSecret(
connection.refreshTokenEncrypted,
this.config.encryptionKey,
REFRESH_TOKEN_PURPOSE,
);
const token = await this.exchangeToken(new URLSearchParams({
client_id: this.config.clientId!,
client_secret: this.config.clientSecret!,
refresh_token: refreshToken,
grant_type: 'refresh_token',
}));
if (!token.access_token) throw new GoogleApiError('Google access expired. Connect again.', 401, true);
await this.db.update(googleConnections).set({
accessTokenEncrypted: encryptSecret(token.access_token, this.config.encryptionKey, ACCESS_TOKEN_PURPOSE),
accessTokenExpiresAt: token.expires_in
? new Date(now.getTime() + token.expires_in * 1_000)
: null,
updatedAt: now,
}).where(eq(googleConnections.userId, userId));
return token.access_token;
}
private async exchangeToken(parameters: URLSearchParams): Promise<GoogleTokenResponse> {
const response = await this.fetchImpl(GOOGLE_TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: parameters,
});
if (!response.ok) {
throw new GoogleApiError('Google authorization failed. Connect again.', response.status, true);
}
return await response.json() as GoogleTokenResponse;
}
private async googleJson<T>(url: URL, token: string, message: string): Promise<T> {
const response = await this.fetchImpl(url, {
headers: { authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new GoogleApiError(message, response.status, response.status === 401 || response.status === 403);
}
return await response.json() as T;
}
private requireConfigured(): void {
if (!this.configured()) {
throw new MutationError(
'google_not_configured',
'Google Sheets import requires OAuth credentials and the settings encryption key.',
503,
);
}
}
}
export function oauthStateHash(state: string): string {
return createHash('sha256').update(state).digest('hex');
}
export function oauthFlowMatches(
flow: Pick<GoogleOauthFlow, 'stateHash' | 'browserBindingHash' | 'expiresAt' | 'consumedAt'>,
state: string,
browserBinding: string,
now: Date,
): boolean {
if (!flow.consumedAt || flow.consumedAt.getTime() !== now.getTime() || flow.expiresAt <= now) return false;
const expected = Buffer.from(flow.stateHash, 'hex');
const actual = Buffer.from(oauthStateHash(state), 'hex');
const expectedBinding = Buffer.from(flow.browserBindingHash, 'hex');
const actualBinding = Buffer.from(oauthStateHash(browserBinding), 'hex');
return (
expected.length === actual.length &&
timingSafeEqual(expected, actual) &&
expectedBinding.length === actualBinding.length &&
timingSafeEqual(expectedBinding, actualBinding)
);
}
export function buildGoogleAuthorizationUrl(input: {
clientId: string;
redirectUri: string;
state: string;
challenge: string;
}): string {
const url = new URL(GOOGLE_AUTHORIZATION_ENDPOINT);
url.searchParams.set('client_id', input.clientId);
url.searchParams.set('redirect_uri', input.redirectUri);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', GOOGLE_OAUTH_SCOPES.join(' '));
url.searchParams.set('state', input.state);
url.searchParams.set('code_challenge', input.challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('access_type', 'offline');
url.searchParams.set('include_granted_scopes', 'true');
url.searchParams.set('prompt', 'consent');
return url.toString();
}
export function googleConnectionMetadata(
configured: boolean,
connection: GoogleConnection | undefined,
): GoogleConnectionMetadata {
return {
configured,
connected: Boolean(connection),
connectedAt: connection?.connectedAt.toISOString() ?? null,
scopes: connection?.scopes ?? [],
};
}
export interface BoundedGoogleRange {
a1: string;
rows: number;
columns: number;
}
export function parseBoundedGoogleRange(
value: string,
grid?: { rowCount: number; columnCount: number },
): BoundedGoogleRange {
const match = value.trim().match(/^([A-Za-z]{1,3})([1-9]\d*):([A-Za-z]{1,3})([1-9]\d*)$/);
if (!match) {
throw new MutationError('invalid_google_range', 'Use a rectangular A1 range such as A1:H500.', 400);
}
const startColumn = columnNumber(match[1]!);
const endColumn = columnNumber(match[3]!);
const startRow = Number(match[2]);
const endRow = Number(match[4]);
if (endColumn < startColumn || endRow < startRow) {
throw new MutationError('invalid_google_range', 'The range end must follow its start.', 400);
}
const columns = endColumn - startColumn + 1;
const rows = endRow - startRow + 1;
if (columns > MAX_IMPORT_COLUMNS || rows > MAX_IMPORT_ROWS + 1) {
throw new MutationError(
'google_range_too_large',
`Select at most ${MAX_IMPORT_COLUMNS} columns and ${MAX_IMPORT_ROWS + 1} rows including the header.`,
400,
);
}
if (grid && (endColumn > grid.columnCount || endRow > grid.rowCount)) {
throw new MutationError('google_range_outside_sheet', 'The selected range extends beyond the sheet grid.', 400);
}
return { a1: `${match[1]!.toUpperCase()}${startRow}:${match[3]!.toUpperCase()}${endRow}`, rows, columns };
}
export function normaliseGoogleValues(
values: readonly (readonly unknown[])[],
requestedColumns: number,
): Omit<ParsedTable, 'fileName' | 'sheetName'> {
const table = values.map((row) => {
if (row.length > requestedColumns) {
throw new MutationError('invalid_google_values', 'Google returned cells outside the requested range.', 502);
}
return row.map((value) => normaliseGoogleCell(value));
});
return normaliseTabularRows(table, [
'Google formula source was not imported; formula cells use only their formatted cached result.',
]);
}
function normaliseGoogleCell(value: unknown): string {
if (value == null) return '';
if (typeof value === 'boolean') return value ? 'true' : 'false';
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw new MutationError('invalid_google_values', 'Google returned a non-finite number.', 502);
return String(value);
}
if (typeof value === 'string') {
if (value.length > MAX_IMPORT_CELL_CHARS) {
throw new MutationError('google_cell_too_large', `A Google Sheets cell exceeds ${MAX_IMPORT_CELL_CHARS} characters.`, 400);
}
return value;
}
throw new MutationError('invalid_google_values', 'Google returned an unsupported cell value.', 502);
}
function columnNumber(letters: string): number {
let result = 0;
for (const letter of letters.toUpperCase()) result = result * 26 + letter.charCodeAt(0) - 64;
return result;
}
function escapeDriveQuery(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
}
+443
View File
@@ -0,0 +1,443 @@
import { createHash } from 'node:crypto';
import {
IMPORT_ENTITY_DEFINITIONS,
type ImportEntity,
type ImportFieldDefinition,
} from '@pig/core';
import type { Database } from '@pig/db';
import {
accounts,
contacts,
demandDeals,
importIdentities,
supplyDeals,
} from '@pig/db';
import { and, eq, inArray, sql } from 'drizzle-orm';
import type { Principal } from '../lib/auth';
import { MutationError } from '../lib/mutation';
import {
MAX_IMPORT_CELL_CHARS,
MAX_IMPORT_COLUMNS,
MAX_IMPORT_ROWS,
} from './tabular-import';
export type ImportTransaction = Parameters<Parameters<Database['transaction']>[0]>[0];
export interface ImportPlanInput {
entity: ImportEntity;
sourceName: string;
headers: string[];
rows: string[][];
mapping: Record<string, string>;
keySourceColumn: string;
}
export interface ImportRowError {
field: string | null;
message: string;
}
export interface ImportPreviewRow {
rowNumber: number;
key: string;
action: 'create' | 'update' | 'error';
recordId: string | null;
values: Record<string, unknown>;
errors: ImportRowError[];
}
export interface ImportPreview {
digest: string;
rows: ImportPreviewRow[];
counts: { create: number; update: number; error: number };
}
export interface ImportCommitResult {
created: number;
updated: number;
total: number;
}
type ImportDb = Database | ImportTransaction;
export class ImportService {
constructor(private readonly db: ImportDb) {}
async preview(input: ImportPlanInput): Promise<ImportPreview> {
assertPlanBounds(input);
const definition = IMPORT_ENTITY_DEFINITIONS[input.entity];
const knownFields = new Set(definition.fields.map((field) => field.key));
const headerSet = new Set(input.headers);
for (const [target, source] of Object.entries(input.mapping)) {
if (!knownFields.has(target)) throw new MutationError('invalid_mapping', `Unknown target field: ${target}.`, 400);
if (!headerSet.has(source)) throw new MutationError('invalid_mapping', `Unknown source column: ${source}.`, 400);
}
if (!headerSet.has(input.keySourceColumn)) {
throw new MutationError('invalid_mapping', 'Select a source column as the stable import key.', 400);
}
const keyIndex = input.headers.indexOf(input.keySourceColumn);
const keys = input.rows.map((row) => (row[keyIndex] ?? '').trim());
const nonemptyKeys = [...new Set(keys.filter(Boolean))];
const identities = nonemptyKeys.length === 0
? []
: await this.db
.select()
.from(importIdentities)
.where(and(
eq(importIdentities.entity, input.entity),
eq(importIdentities.keyColumn, input.keySourceColumn),
inArray(importIdentities.keyValue, nonemptyKeys),
));
const identityByKey = new Map(identities.map((identity) => [identity.keyValue, identity]));
const duplicateKeys = findDuplicateImportKeys(keys);
const rows = input.rows.map((row, index): ImportPreviewRow => {
const key = keys[index]!;
const identity = identityByKey.get(key);
const errors: ImportRowError[] = [];
if (!key) errors.push({ field: null, message: 'The selected source key is blank.' });
if (duplicateKeys.has(key)) errors.push({ field: null, message: 'The source key is duplicated in this file.' });
const converted = convertImportRow(
input.entity,
input.headers,
row,
input.mapping,
!identity,
);
errors.push(...converted.errors);
return {
rowNumber: index + 2,
key,
action: errors.length > 0 ? 'error' : identity ? 'update' : 'create',
recordId: identity?.recordId ?? null,
values: converted.values,
errors,
};
});
await this.validateRelationships(input.entity, rows);
const counts = rows.reduce(
(total, row) => ({ ...total, [row.action]: total[row.action] + 1 }),
{ create: 0, update: 0, error: 0 },
);
const digest = planDigest(input, rows);
return { digest, rows, counts };
}
private async validateRelationships(
entity: ImportEntity,
previewRows: ImportPreviewRow[],
): Promise<void> {
if (entity !== 'contact' && entity !== 'demand_deal' && entity !== 'supply_deal') return;
const accountIds = [...new Set(previewRows
.map((row) => row.values.accountId)
.filter((value): value is string => typeof value === 'string'))];
if (accountIds.length === 0) return;
const found = await this.db
.select({ id: accounts.id, side: accounts.side })
.from(accounts)
.where(inArray(accounts.id, accountIds));
const accountsById = new Map(found.map((account) => [account.id, account]));
for (const row of previewRows) {
const accountId = row.values.accountId;
if (typeof accountId !== 'string') continue;
const account = accountsById.get(accountId);
if (!account) row.errors.push({ field: 'accountId', message: 'The account does not exist.' });
else if (entity === 'demand_deal' && account.side === 'supply') {
row.errors.push({ field: 'accountId', message: 'A demand deal needs a demand or dual-sided account.' });
} else if (entity === 'supply_deal' && account.side === 'demand') {
row.errors.push({ field: 'accountId', message: 'A supply deal needs a supply or dual-sided account.' });
}
if (row.errors.length > 0) row.action = 'error';
}
}
async commit(
input: ImportPlanInput & { previewDigest: string },
principal: Principal,
now: Date,
): Promise<ImportCommitResult> {
if (!('execute' in this.db)) throw new Error('Import commit requires a database transaction.');
await this.db.execute(
sql`select pg_advisory_xact_lock(hashtextextended(${`pig:import:${input.entity}`}, 0))`,
);
const preview = await this.preview(input);
if (preview.digest !== input.previewDigest) {
throw new MutationError(
'stale_import_preview',
'The import plan changed after preview. Run the dry run again before committing.',
409,
);
}
if (preview.counts.error > 0) {
throw new MutationError(
'invalid_import_rows',
'Fix every row error and run the dry run again before committing.',
409,
);
}
let created = 0;
let updated = 0;
for (const row of preview.rows) {
const recordId = await this.writeRecord(input.entity, row, principal, now);
if (row.action === 'create') {
created += 1;
await this.db.insert(importIdentities).values({
entity: input.entity,
keyColumn: input.keySourceColumn,
keyValue: row.key,
recordId,
importedByUserId: principal.userId,
createdAt: now,
updatedAt: now,
});
} else {
updated += 1;
await this.db
.update(importIdentities)
.set({ importedByUserId: principal.userId, updatedAt: now })
.where(and(
eq(importIdentities.entity, input.entity),
eq(importIdentities.keyColumn, input.keySourceColumn),
eq(importIdentities.keyValue, row.key),
));
}
}
return { created, updated, total: preview.rows.length };
}
private async writeRecord(
entity: ImportEntity,
row: ImportPreviewRow,
principal: Principal,
now: Date,
): Promise<string> {
const values = row.values;
if (row.action === 'create') {
if (entity === 'account') {
const [record] = await this.db.insert(accounts).values({
...values,
ownerUserId: principal.userId,
source: 'import',
confidence: 'unverified',
createdAt: now,
updatedAt: now,
} as typeof accounts.$inferInsert).returning({ id: accounts.id });
if (record) return record.id;
} else if (entity === 'contact') {
const [record] = await this.db.insert(contacts).values({
...values,
ownerUserId: principal.userId,
source: 'import',
confidence: 'unverified',
createdAt: now,
updatedAt: now,
} as typeof contacts.$inferInsert).returning({ id: contacts.id });
if (record) return record.id;
} else if (entity === 'demand_deal') {
const [record] = await this.db.insert(demandDeals).values({
...values,
ownerUserId: principal.userId,
stageChangedAt: now,
createdAt: now,
updatedAt: now,
} as typeof demandDeals.$inferInsert).returning({ id: demandDeals.id });
if (record) return record.id;
} else {
const [record] = await this.db.insert(supplyDeals).values({
...values,
ownerUserId: principal.userId,
stageChangedAt: now,
createdAt: now,
updatedAt: now,
} as typeof supplyDeals.$inferInsert).returning({ id: supplyDeals.id });
if (record) return record.id;
}
throw new MutationError('write_failed', `Import row ${row.rowNumber} was not created.`, 409);
}
const recordId = row.recordId;
if (!recordId) throw new MutationError('missing_import_identity', 'The import identity is incomplete.', 409);
if (entity === 'account') {
const [record] = await this.db.update(accounts).set({
...values,
source: 'import',
updatedAt: now,
} as Partial<typeof accounts.$inferInsert>).where(eq(accounts.id, recordId)).returning({ id: accounts.id });
if (record) return record.id;
} else if (entity === 'contact') {
const [record] = await this.db.update(contacts).set({
...values,
source: 'import',
updatedAt: now,
} as Partial<typeof contacts.$inferInsert>).where(eq(contacts.id, recordId)).returning({ id: contacts.id });
if (record) return record.id;
} else if (entity === 'demand_deal') {
const [record] = await this.db.update(demandDeals).set({
...values,
updatedAt: now,
} as Partial<typeof demandDeals.$inferInsert>).where(eq(demandDeals.id, recordId)).returning({ id: demandDeals.id });
if (record) return record.id;
} else {
const [record] = await this.db.update(supplyDeals).set({
...values,
updatedAt: now,
} as Partial<typeof supplyDeals.$inferInsert>).where(eq(supplyDeals.id, recordId)).returning({ id: supplyDeals.id });
if (record) return record.id;
}
throw new MutationError('missing_import_record', `The record for import row ${row.rowNumber} no longer exists.`, 409);
}
}
export function convertImportRow(
entity: ImportEntity,
headers: readonly string[],
row: readonly string[],
mapping: Readonly<Record<string, string>>,
isCreate: boolean,
): { values: Record<string, unknown>; errors: ImportRowError[] } {
const values: Record<string, unknown> = {};
const errors: ImportRowError[] = [];
for (const field of IMPORT_ENTITY_DEFINITIONS[entity].fields) {
const sourceColumn = mapping[field.key];
if (!sourceColumn) {
if (isCreate && field.required) errors.push({ field: field.key, message: `${field.label} must be mapped.` });
continue;
}
const sourceIndex = headers.indexOf(sourceColumn);
const raw = sourceIndex < 0 ? '' : (row[sourceIndex] ?? '').trim();
if (!raw) {
if (field.required) errors.push({ field: field.key, message: `${field.label} is required.` });
else if (field.clearable !== false) values[field.key] = null;
continue;
}
const converted = convertCell(field, raw);
if (converted.error) errors.push({ field: field.key, message: converted.error });
else values[field.key] = converted.value;
}
if (entity === 'account') {
const side = values.side;
if (values.supplierType && side === 'demand') {
errors.push({ field: 'supplierType', message: 'Supplier type requires a supply or dual-sided account.' });
}
if (values.customerSegment && side === 'supply') {
errors.push({ field: 'customerSegment', message: 'Customer segment requires a demand or dual-sided account.' });
}
}
return { values, errors };
}
function convertCell(
field: ImportFieldDefinition,
raw: string,
): { value?: unknown; error?: string } {
if (raw.length > (field.maxLength ?? MAX_IMPORT_CELL_CHARS)) {
return { error: `${field.label} exceeds ${field.maxLength ?? MAX_IMPORT_CELL_CHARS} characters.` };
}
if (field.kind === 'text') return { value: raw };
if (field.kind === 'email') {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(raw)
? { value: raw.toLocaleLowerCase() }
: { error: `${field.label} is not a complete email address.` };
}
if (field.kind === 'url') {
try {
const url = new URL(raw);
return ['http:', 'https:'].includes(url.protocol)
? { value: url.toString() }
: { error: `${field.label} must use http or https.` };
} catch {
return { error: `${field.label} is not a valid URL.` };
}
}
if (field.kind === 'uuid') {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(raw)
? { value: raw.toLocaleLowerCase() }
: { error: `${field.label} must be a PIG UUID.` };
}
if (field.kind === 'integer') {
if (!/^-?\d+$/.test(raw)) return { error: `${field.label} must be a whole number.` };
const value = Number(raw);
if (!Number.isSafeInteger(value)) return { error: `${field.label} is outside the supported range.` };
if (field.min != null && value < field.min) return { error: `${field.label} must be at least ${field.min}.` };
if (field.max != null && value > field.max) return { error: `${field.label} must be at most ${field.max}.` };
return { value };
}
if (field.kind === 'decimal') {
const value = Number(raw);
if (!Number.isFinite(value)) return { error: `${field.label} must be a number.` };
if (field.min != null && value < field.min) return { error: `${field.label} must be at least ${field.min}.` };
if (field.max != null && value > field.max) return { error: `${field.label} must be at most ${field.max}.` };
return { value };
}
if (field.kind === 'boolean') {
const value = raw.toLocaleLowerCase();
if (['true', 'yes', '1'].includes(value)) return { value: true };
if (['false', 'no', '0'].includes(value)) return { value: false };
return { error: `${field.label} must be true/false, yes/no, or 1/0.` };
}
if (field.kind === 'date') {
const value = new Date(raw);
return Number.isNaN(value.getTime())
? { error: `${field.label} is not a valid date.` }
: { value };
}
if (field.kind === 'currency') {
return /^[A-Za-z]{3}$/.test(raw)
? { value: raw.toUpperCase() }
: { error: `${field.label} must be a three-letter currency code.` };
}
return field.options?.includes(raw)
? { value: raw }
: { error: `${field.label} must be one of: ${field.options?.join(', ')}.` };
}
export function findDuplicateImportKeys(keys: readonly string[]): Set<string> {
const once = new Set<string>();
const duplicates = new Set<string>();
for (const key of keys) {
if (!key) continue;
if (once.has(key)) duplicates.add(key);
else once.add(key);
}
return duplicates;
}
function assertPlanBounds(input: ImportPlanInput): void {
if (!input.sourceName.trim() || input.sourceName.length > 255) {
throw new MutationError('invalid_import', 'The source file name is invalid.', 400);
}
if (input.headers.length === 0 || input.headers.length > MAX_IMPORT_COLUMNS) {
throw new MutationError('invalid_import', 'Imports need 1100 columns.', 400);
}
if (input.rows.length === 0 || input.rows.length > MAX_IMPORT_ROWS) {
throw new MutationError('invalid_import', 'Imports need 12,000 data rows.', 400);
}
if (input.headers.some((header) => !header || header.length > 255)) {
throw new MutationError('invalid_import', 'Source headers must be non-empty and at most 255 characters.', 400);
}
if (input.rows.some((row) => row.length > MAX_IMPORT_COLUMNS || row.some((cell) => cell.length > MAX_IMPORT_CELL_CHARS))) {
throw new MutationError('invalid_import', 'The imported table exceeds the row, column, or cell limits.', 400);
}
}
function planDigest(input: ImportPlanInput, rows: readonly ImportPreviewRow[]): string {
const mapping = Object.fromEntries(Object.entries(input.mapping).sort(([left], [right]) => left.localeCompare(right)));
return createHash('sha256').update(JSON.stringify({
entity: input.entity,
sourceName: input.sourceName,
headers: input.headers,
rows: input.rows,
mapping,
keySourceColumn: input.keySourceColumn,
decisions: rows.map((row) => ({
rowNumber: row.rowNumber,
key: row.key,
action: row.action,
recordId: row.recordId,
errors: row.errors,
})),
})).digest('hex');
}
@@ -0,0 +1,308 @@
import { createHash, randomUUID } from 'node:crypto';
import type { Database } from '@pig/db';
import { channelLinks, notificationOutbox, type NotificationOutboxItem } from '@pig/db';
import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm';
import { z } from 'zod';
import type { AvailabilityRow } from './capacity';
import {
NotificationDeliveryError,
NOTIFIER_PROVIDERS,
isNotifierProvider,
type IdleCapacityNotification,
type Notification,
type Notifier,
type StageChangeNotification,
} from './notifier';
type Transaction = Parameters<Parameters<Database['transaction']>[0]>[0];
const notificationSchema = z.discriminatedUnion('kind', [
z.object({
kind: z.literal('stage_change'),
accountId: z.string().uuid(),
dealId: z.string().uuid(),
dealSide: z.enum(['demand', 'supply']),
dealName: z.string(),
fromStage: z.string(),
toStage: z.string(),
changedAt: z.string().datetime(),
}),
z.object({
kind: z.literal('idle_capacity'),
accountId: z.string().uuid(),
commitmentId: z.string().uuid(),
commitmentName: z.string(),
gpuType: z.string(),
idleGpuHours: z.number().nonnegative(),
idleCostCents: z.number().int().nonnegative(),
utilisation: z.number().min(0).max(1),
observedAt: z.string().datetime(),
}),
]);
export interface StageChangeEvent extends Omit<StageChangeNotification, 'kind'> {
requestedByUserId?: string;
}
export interface IdleCapacityRow extends AvailabilityRow {
idleGpuHours: number;
idleCostCents: number;
}
export class NotificationOutbox {
constructor(private readonly db: Database) {}
async enqueueStageChange(tx: Transaction, event: StageChangeEvent): Promise<number> {
if (event.fromStage === event.toStage) return 0;
const links = await tx
.select()
.from(channelLinks)
.where(
and(
inArray(channelLinks.platform, [...NOTIFIER_PROVIDERS]),
eq(channelLinks.accountId, event.accountId),
),
);
const subscribedLinks = links.filter(
(link) => isNotifierProvider(link.platform) && link.notifyOn.includes('stage_change'),
);
if (subscribedLinks.length === 0) return 0;
const notification: StageChangeNotification = { kind: 'stage_change', ...event };
const eventKey = hashKey([
notification.kind,
notification.dealSide,
notification.dealId,
notification.changedAt,
notification.fromStage,
notification.toStage,
]);
const inserted = await tx
.insert(notificationOutbox)
.values(
subscribedLinks.map((link) => ({
provider: link.platform,
kind: notification.kind,
linkId: link.id,
workspaceId: link.workspaceId,
destination: link.channelId,
payload: { ...notification },
idempotencyKey: `${link.platform}:${link.id}:${eventKey}`,
requestedByUserId: event.requestedByUserId,
})),
)
.onConflictDoNothing({ target: notificationOutbox.idempotencyKey })
.returning({ id: notificationOutbox.id });
return inserted.length;
}
async enqueueIdleCapacity(rows: IdleCapacityRow[], observedAt = new Date()): Promise<number> {
const day = observedAt.toISOString().slice(0, 10);
return this.db.transaction(async (tx) => {
let count = 0;
for (const row of rows) {
const links = await tx
.select()
.from(channelLinks)
.where(
and(
inArray(channelLinks.platform, [...NOTIFIER_PROVIDERS]),
eq(channelLinks.accountId, row.accountId),
),
);
const subscribedLinks = links.filter(
(link) => isNotifierProvider(link.platform) && link.notifyOn.includes('idle_capacity'),
);
if (subscribedLinks.length === 0) continue;
const notification: IdleCapacityNotification = {
kind: 'idle_capacity',
accountId: row.accountId,
commitmentId: row.commitmentId,
commitmentName: row.name,
gpuType: row.gpuType,
idleGpuHours: row.idleGpuHours,
idleCostCents: row.idleCostCents,
utilisation: row.utilisation,
observedAt: observedAt.toISOString(),
};
const inserted = await tx
.insert(notificationOutbox)
.values(
subscribedLinks.map((link) => ({
provider: link.platform,
kind: notification.kind,
linkId: link.id,
workspaceId: link.workspaceId,
destination: link.channelId,
payload: { ...notification },
idempotencyKey: `${link.platform}:${link.id}:idle:${row.commitmentId}:${day}`,
})),
)
.onConflictDoNothing({ target: notificationOutbox.idempotencyKey })
.returning({ id: notificationOutbox.id });
count += inserted.length;
}
return count;
});
}
}
export interface NotificationWorkerOptions {
workerId?: string;
leaseSeconds?: number;
pollIntervalMs?: number;
}
export class NotificationWorker {
private readonly workerId: string;
private readonly leaseSeconds: number;
private readonly pollIntervalMs: number;
constructor(
private readonly db: Database,
private readonly notifier: Notifier,
options: NotificationWorkerOptions = {},
) {
this.workerId = options.workerId ?? `notification-${process.pid}-${randomUUID()}`;
this.leaseSeconds = options.leaseSeconds ?? 30;
this.pollIntervalMs = options.pollIntervalMs ?? 2_000;
}
start(): () => void {
let stopped = false;
let working = false;
const tick = () => {
if (stopped || working) return;
working = true;
void this.processNext()
.catch(() => {})
.finally(() => {
working = false;
});
};
const timer = setInterval(tick, this.pollIntervalMs);
timer.unref();
tick();
return () => {
stopped = true;
clearInterval(timer);
};
}
async processNext(now = new Date()): Promise<boolean> {
const item = await this.claimNext(now);
if (!item) return false;
const parsed = notificationSchema.safeParse(item.payload);
if (!parsed.success) {
await this.finishFailure(item, new NotificationDeliveryError('invalid_payload', false), now);
return true;
}
try {
const receipt = await this.notifier.send({
idempotencyKey: item.idempotencyKey,
destination: item.destination,
workspaceId: item.workspaceId,
notification: parsed.data as Notification,
});
await this.db
.update(notificationOutbox)
.set({
status: 'delivered',
deliveredAt: new Date(),
externalId: receipt.externalId,
leasedBy: null,
leasedUntil: null,
error: null,
})
.where(
and(
eq(notificationOutbox.id, item.id),
eq(notificationOutbox.leasedBy, this.workerId),
eq(notificationOutbox.status, 'leased'),
),
);
} catch (error) {
const deliveryError =
error instanceof NotificationDeliveryError
? error
: new NotificationDeliveryError('provider_error', true);
await this.finishFailure(item, deliveryError, new Date());
}
return true;
}
private async claimNext(now: Date): Promise<NotificationOutboxItem | null> {
return this.db.transaction(async (tx) => {
const [candidate] = await tx
.select()
.from(notificationOutbox)
.where(
and(
eq(notificationOutbox.provider, this.notifier.provider),
or(
eq(notificationOutbox.status, 'pending'),
and(
eq(notificationOutbox.status, 'leased'),
or(isNull(notificationOutbox.leasedUntil), lt(notificationOutbox.leasedUntil, now)),
),
),
lte(notificationOutbox.dueAt, now),
sql`${notificationOutbox.attempts} < ${notificationOutbox.maxAttempts}`,
),
)
.orderBy(asc(notificationOutbox.dueAt), asc(notificationOutbox.createdAt))
.limit(1)
.for('update', { skipLocked: true });
if (!candidate) return null;
const [claimed] = await tx
.update(notificationOutbox)
.set({
status: 'leased',
leasedBy: this.workerId,
leasedUntil: new Date(now.getTime() + this.leaseSeconds * 1_000),
attempts: sql`${notificationOutbox.attempts} + 1`,
error: null,
})
.where(eq(notificationOutbox.id, candidate.id))
.returning();
return claimed ?? null;
});
}
private async finishFailure(
item: NotificationOutboxItem,
error: NotificationDeliveryError,
now: Date,
): Promise<void> {
const retry = error.retryable && item.attempts < item.maxAttempts;
const backoff = error.retryAfterMs ?? retryBackoffMs(item.attempts);
await this.db
.update(notificationOutbox)
.set({
status: retry ? 'pending' : 'failed',
dueAt: retry ? new Date(now.getTime() + backoff) : item.dueAt,
leasedBy: null,
leasedUntil: null,
error: error.code,
})
.where(
and(
eq(notificationOutbox.id, item.id),
eq(notificationOutbox.leasedBy, this.workerId),
eq(notificationOutbox.status, 'leased'),
),
);
}
}
export function retryBackoffMs(attempts: number): number {
return Math.min(60 * 60_000, 30_000 * 2 ** Math.max(0, attempts - 1));
}
function hashKey(parts: string[]): string {
return createHash('sha256').update(JSON.stringify(parts)).digest('hex');
}
+59
View File
@@ -0,0 +1,59 @@
export interface StageChangeNotification {
kind: 'stage_change';
accountId: string;
dealId: string;
dealSide: 'demand' | 'supply';
dealName: string;
fromStage: string;
toStage: string;
changedAt: string;
}
export interface IdleCapacityNotification {
kind: 'idle_capacity';
accountId: string;
commitmentId: string;
commitmentName: string;
gpuType: string;
idleGpuHours: number;
idleCostCents: number;
utilisation: number;
observedAt: string;
}
export type Notification = StageChangeNotification | IdleCapacityNotification;
export const NOTIFIER_PROVIDERS = ['slack', 'buzz'] as const;
export type NotifierProvider = (typeof NOTIFIER_PROVIDERS)[number];
export function isNotifierProvider(value: string): value is NotifierProvider {
return (NOTIFIER_PROVIDERS as readonly string[]).includes(value);
}
export interface NotificationEnvelope {
idempotencyKey: string;
destination: string;
workspaceId?: string | null;
notification: Notification;
}
export interface NotificationReceipt {
externalId: string | null;
}
/** Provider adapters perform one attempt; the durable worker owns retries. */
export interface Notifier {
readonly provider: string;
send(envelope: NotificationEnvelope, signal?: AbortSignal): Promise<NotificationReceipt>;
}
export class NotificationDeliveryError extends Error {
constructor(
readonly code: string,
readonly retryable: boolean,
readonly retryAfterMs?: number,
) {
super(`Notification delivery failed (${code}).`);
this.name = 'NotificationDeliveryError';
}
}
+463
View File
@@ -0,0 +1,463 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
import {
MAX_IMPORT_CELL_CHARS,
MAX_IMPORT_COLUMNS,
MAX_IMPORT_ROWS,
} from './tabular-import';
export const NOTION_API_VERSION = '2025-09-03';
export const NOTION_OAUTH_TTL_MS = 10 * 60 * 1_000;
const NOTION_API_BASE = 'https://api.notion.com/v1';
export interface NotionOAuthAttempt {
state: string;
stateHash: string;
verifier: string;
verifierHash: string;
expiresAt: Date;
}
export interface NotionConnectionMetadata {
id: string;
workspaceId: string;
workspaceName: string | null;
workspaceIcon: string | null;
connectedAt: string;
}
export interface NotionCredentials {
accessToken: string;
refreshToken?: string;
expiresAt?: string;
}
export interface NotionDataSource {
id: string;
databaseId: string | null;
name: string;
url: string | null;
icon: string | null;
}
export interface UnsupportedNotionProperty {
name: string;
type: string;
reason: string;
}
export interface NotionCellError {
pageId: string;
property: string;
type: string;
message: string;
}
export interface NotionMaterializedTable {
fileName: string;
sheetName: null;
headers: string[];
rows: string[][];
warnings: string[];
unsupportedProperties: UnsupportedNotionProperty[];
cellErrors: NotionCellError[];
}
type JsonRecord = Record<string, unknown>;
type FetchLike = typeof fetch;
export class NotionApiError extends Error {
constructor(readonly status: number, message = 'Notion rejected the request.') {
super(message);
this.name = 'NotionApiError';
}
}
export function hashOAuthValue(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
export function createNotionOAuthAttempt(
now = new Date(),
random: (size: number) => Buffer = randomBytes,
): NotionOAuthAttempt {
const state = random(32).toString('base64url');
const verifier = random(32).toString('base64url');
return {
state,
stateHash: hashOAuthValue(state),
verifier,
verifierHash: hashOAuthValue(verifier),
expiresAt: new Date(now.getTime() + NOTION_OAUTH_TTL_MS),
};
}
export function verifyNotionOAuthAttempt(
verifier: string | undefined,
expectedHash: string,
expiresAt: Date,
now = new Date(),
): boolean {
if (!verifier || expiresAt.getTime() <= now.getTime()) return false;
const actual = Buffer.from(hashOAuthValue(verifier), 'hex');
const expected = Buffer.from(expectedHash, 'hex');
return actual.length === expected.length && timingSafeEqual(actual, expected);
}
export function notionAuthorizationUrl(input: {
clientId: string;
redirectUri: string;
state: string;
}): string {
const url = new URL('https://api.notion.com/v1/oauth/authorize');
url.searchParams.set('client_id', input.clientId);
url.searchParams.set('redirect_uri', input.redirectUri);
url.searchParams.set('response_type', 'code');
url.searchParams.set('owner', 'user');
url.searchParams.set('state', input.state);
// Notion does not document RFC 7636 parameters. The server-side verifier
// provides browser binding without sending fields the provider may reject.
return url.toString();
}
export function notionConnectionMetadata(connection: {
id: string;
workspaceId: string;
workspaceName: string | null;
workspaceIcon: string | null;
createdAt: Date;
}): NotionConnectionMetadata {
return {
id: connection.id,
workspaceId: connection.workspaceId,
workspaceName: connection.workspaceName,
workspaceIcon: connection.workspaceIcon,
connectedAt: connection.createdAt.toISOString(),
};
}
export class NotionClient {
constructor(private readonly request: FetchLike = fetch) {}
async exchangeAuthorizationCode(input: {
clientId: string;
clientSecret: string;
code: string;
redirectUri: string;
}): Promise<JsonRecord> {
return this.fetchJson('/oauth/token', undefined, {
method: 'POST',
headers: {
Authorization: `Basic ${Buffer.from(`${input.clientId}:${input.clientSecret}`).toString('base64')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
grant_type: 'authorization_code',
code: input.code,
redirect_uri: input.redirectUri,
}),
});
}
async searchDataSources(accessToken: string): Promise<NotionDataSource[]> {
const results = await collectNotionPages<JsonRecord>(async (cursor) => {
const body: JsonRecord = {
filter: { property: 'object', value: 'data_source' },
sort: { direction: 'descending', timestamp: 'last_edited_time' },
page_size: 100,
};
if (cursor) body.start_cursor = cursor;
return this.fetchJson('/search', accessToken, {
method: 'POST',
body: JSON.stringify(body),
});
}, 2_000);
return results.map(toDataSource).filter((value): value is NotionDataSource => value !== null);
}
async retrieveDataSource(accessToken: string, dataSourceId: string): Promise<JsonRecord> {
return this.fetchJson(`/data_sources/${encodeURIComponent(dataSourceId)}`, accessToken);
}
async queryDataSource(accessToken: string, dataSourceId: string): Promise<JsonRecord[]> {
return collectNotionPages<JsonRecord>(async (cursor) => {
const body: JsonRecord = { page_size: 100 };
if (cursor) body.start_cursor = cursor;
return this.fetchJson(`/data_sources/${encodeURIComponent(dataSourceId)}/query`, accessToken, {
method: 'POST',
body: JSON.stringify(body),
});
}, MAX_IMPORT_ROWS + 1);
}
async retrievePropertyItems(
accessToken: string,
pageId: string,
propertyId: string,
): Promise<JsonRecord[]> {
return collectNotionPages<JsonRecord>(async (cursor) => {
const query = new URLSearchParams({ page_size: '100' });
if (cursor) query.set('start_cursor', cursor);
return this.fetchJson(
`/pages/${encodeURIComponent(pageId)}/properties/${encodeURIComponent(propertyId)}?${query}`,
accessToken,
);
}, 10_000);
}
private async fetchJson(
path: string,
accessToken?: string,
init: RequestInit = {},
): Promise<JsonRecord> {
const headers = new Headers(init.headers);
headers.set('Accept', 'application/json');
headers.set('Notion-Version', NOTION_API_VERSION);
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`);
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json');
const response = await this.request(`${NOTION_API_BASE}${path}`, { ...init, headers });
if (!response.ok) {
const message = response.status === 401
? 'The Notion connection is no longer authorized. Reconnect it and try again.'
: `Notion request failed with status ${response.status}.`;
throw new NotionApiError(response.status, message);
}
const value: unknown = await response.json();
if (!isRecord(value)) throw new NotionApiError(502, 'Notion returned an invalid response.');
return value;
}
}
export async function collectNotionPages<T extends JsonRecord>(
fetchPage: (cursor?: string) => Promise<JsonRecord>,
maxItems: number,
): Promise<T[]> {
const collected: T[] = [];
let cursor: string | undefined;
for (let page = 0; page < 100; page += 1) {
const response = await fetchPage(cursor);
const results = Array.isArray(response.results)
? response.results.filter(isRecord) as T[]
: [];
collected.push(...results);
if (collected.length >= maxItems) return collected.slice(0, maxItems);
if (response.has_more !== true || typeof response.next_cursor !== 'string') return collected;
cursor = response.next_cursor;
}
throw new NotionApiError(422, 'Notion pagination exceeded the safety limit.');
}
export async function materializeNotionDataSource(
client: NotionClient,
accessToken: string,
dataSourceId: string,
): Promise<NotionMaterializedTable> {
const [source, pages] = await Promise.all([
client.retrieveDataSource(accessToken, dataSourceId),
client.queryDataSource(accessToken, dataSourceId),
]);
if (pages.length > MAX_IMPORT_ROWS) {
throw new NotionApiError(422, `Notion imports may not exceed ${MAX_IMPORT_ROWS} rows.`);
}
const schema = isRecord(source.properties) ? source.properties : {};
const unsupportedProperties: UnsupportedNotionProperty[] = [];
const supportedHeaders: string[] = [];
for (const [name, definition] of Object.entries(schema)) {
const type = isRecord(definition) && typeof definition.type === 'string'
? definition.type
: 'unknown';
const reason = unsupportedReason(type);
if (reason) unsupportedProperties.push({ name, type, reason });
else if (supportedHeaders.length < MAX_IMPORT_COLUMNS - 2) supportedHeaders.push(name);
else unsupportedProperties.push({
name,
type,
reason: `A15 is limited to ${MAX_IMPORT_COLUMNS} columns so A14 can validate the plan safely.`,
});
}
const cellErrors: NotionCellError[] = [];
const rows: string[][] = [];
for (const page of pages) {
const pageId = typeof page.id === 'string' ? page.id : '';
const properties = isRecord(page.properties) ? page.properties : {};
const row = [pageId, typeof page.url === 'string' ? page.url : ''];
for (const header of supportedHeaders) {
const property = properties[header];
if (!isRecord(property)) {
row.push('');
cellErrors.push({ pageId, property: header, type: 'unknown', message: 'The page omitted this property.' });
continue;
}
const completed = await completePaginatedProperty(client, accessToken, pageId, property);
const flattened = flattenNotionProperty(completed);
if (flattened.error) {
cellErrors.push({
pageId,
property: header,
type: typeof property.type === 'string' ? property.type : 'unknown',
message: flattened.error,
});
row.push('');
} else if ((flattened.value ?? '').length > MAX_IMPORT_CELL_CHARS) {
cellErrors.push({
pageId,
property: header,
type: typeof property.type === 'string' ? property.type : 'unknown',
message: `The flattened value exceeds ${MAX_IMPORT_CELL_CHARS} characters.`,
});
row.push('');
} else {
row.push(flattened.value ?? '');
}
}
rows.push(row);
}
const warnings = [
...unsupportedProperties.map(({ name, type }) => `${name} (${type}) was not imported.`),
...(cellErrors.length > 0 ? [`${cellErrors.length} Notion cell values could not be flattened and were left blank.`] : []),
];
return {
fileName: `Notion - ${notionTitle(source)}`,
sheetName: null,
headers: ['Notion page ID', 'Notion page URL', ...supportedHeaders],
rows,
warnings,
unsupportedProperties,
cellErrors,
};
}
export function flattenNotionProperty(property: JsonRecord): { value?: string; error?: string } {
const type = typeof property.type === 'string' ? property.type : 'unknown';
const value = property[type];
if (['title', 'rich_text'].includes(type)) return { value: plainText(value) };
if (type === 'number') return { value: value == null ? '' : String(value) };
if (type === 'checkbox') return typeof value === 'boolean'
? { value: value ? 'true' : 'false' }
: { error: 'Notion returned a non-boolean checkbox.' };
if (['url', 'email', 'phone_number', 'created_time', 'last_edited_time'].includes(type)) {
return { value: value == null ? '' : String(value) };
}
if (['select', 'status'].includes(type)) {
return { value: isRecord(value) && typeof value.name === 'string' ? value.name : '' };
}
if (type === 'multi_select') return { value: names(value).join(', ') };
if (type === 'date') {
if (!isRecord(value)) return { value: '' };
const start = typeof value.start === 'string' ? value.start : '';
const end = typeof value.end === 'string' ? value.end : '';
return { value: end ? `${start}/${end}` : start };
}
if (type === 'people') return { value: people(value).join(', ') };
if (type === 'files') return { value: fileUrls(value).join(', ') };
if (type === 'relation') return { value: ids(value).join(', ') };
if (['created_by', 'last_edited_by'].includes(type)) {
if (!isRecord(value)) return { value: '' };
return { value: typeof value.name === 'string' ? value.name : typeof value.id === 'string' ? value.id : '' };
}
if (type === 'unique_id') {
if (!isRecord(value) || typeof value.number !== 'number') return { value: '' };
return { value: `${typeof value.prefix === 'string' ? value.prefix : ''}${value.number}` };
}
if (type === 'formula' || type === 'rollup') {
if (!isRecord(value) || typeof value.type !== 'string') return { error: `Notion returned an incomplete ${type}.` };
if (value.type === 'array') {
if (!Array.isArray(value.array)) return { error: `Notion returned an invalid ${type} array.` };
const flattened = value.array.filter(isRecord).map(flattenNotionProperty);
const error = flattened.find((item) => item.error)?.error;
return error ? { error } : { value: flattened.map((item) => item.value ?? '').filter(Boolean).join(', ') };
}
return flattenNotionProperty({ type: value.type, [value.type]: value[value.type] });
}
return { error: unsupportedReason(type) ?? `Unsupported Notion property type: ${type}.` };
}
async function completePaginatedProperty(
client: NotionClient,
accessToken: string,
pageId: string,
property: JsonRecord,
): Promise<JsonRecord> {
const type = typeof property.type === 'string' ? property.type : '';
const value = property[type];
const needsCompletion = type === 'relation'
? property.has_more === true
: ['title', 'rich_text', 'people'].includes(type) && Array.isArray(value) && value.length >= 25;
if (!needsCompletion || typeof property.id !== 'string' || !pageId) return property;
const items = await client.retrievePropertyItems(accessToken, pageId, property.id);
const completedValues = items.map((item) => item[type]).filter((item) => item != null);
return { ...property, [type]: completedValues, has_more: false };
}
function unsupportedReason(type: string): string | null {
if (['button', 'verification', 'place'].includes(type)) {
return `Notion ${type} values do not have a stable tabular representation.`;
}
const supported = [
'title', 'rich_text', 'number', 'checkbox', 'url', 'email', 'phone_number',
'created_time', 'last_edited_time', 'select', 'status', 'multi_select', 'date',
'people', 'files', 'relation', 'created_by', 'last_edited_by', 'unique_id',
'formula', 'rollup',
];
return supported.includes(type) ? null : `Notion property type ${type} is not supported.`;
}
function toDataSource(value: JsonRecord): NotionDataSource | null {
if (typeof value.id !== 'string') return null;
const parent = isRecord(value.parent) ? value.parent : {};
return {
id: value.id,
databaseId: typeof parent.database_id === 'string' ? parent.database_id : null,
name: notionTitle(value),
url: typeof value.url === 'string' ? value.url : null,
icon: isRecord(value.icon) && value.icon.type === 'emoji' && typeof value.icon.emoji === 'string'
? value.icon.emoji
: null,
};
}
function notionTitle(value: JsonRecord): string {
const title = plainText(value.title);
return title || (typeof value.name === 'string' ? value.name : 'Untitled data source');
}
function plainText(value: unknown): string {
if (!Array.isArray(value)) return '';
return value.filter(isRecord).map((item) => {
if (typeof item.plain_text === 'string') return item.plain_text;
const nested = isRecord(item.text) ? item.text : null;
return nested && typeof nested.content === 'string' ? nested.content : '';
}).join('');
}
function names(value: unknown): string[] {
return Array.isArray(value)
? value.filter(isRecord).flatMap((item) => typeof item.name === 'string' ? [item.name] : [])
: [];
}
function people(value: unknown): string[] {
return Array.isArray(value) ? value.filter(isRecord).map((person) => {
if (typeof person.name === 'string') return person.name;
return typeof person.id === 'string' ? person.id : '';
}).filter(Boolean) : [];
}
function fileUrls(value: unknown): string[] {
return Array.isArray(value) ? value.filter(isRecord).flatMap((item) => {
const file = isRecord(item.file) ? item.file : isRecord(item.external) ? item.external : null;
return file && typeof file.url === 'string' ? [file.url] : [];
}) : [];
}
function ids(value: unknown): string[] {
return Array.isArray(value)
? value.filter(isRecord).flatMap((item) => typeof item.id === 'string' ? [item.id] : [])
: [];
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
+124
View File
@@ -0,0 +1,124 @@
import { createHash } from 'node:crypto';
import {
NotificationDeliveryError,
type Notification,
type NotificationEnvelope,
type NotificationReceipt,
type Notifier,
} from './notifier';
interface SlackNotifierOptions {
botToken: string;
fetchImpl?: typeof fetch;
apiBase?: string;
}
interface SlackResponse {
ok?: boolean;
error?: string;
ts?: string;
}
export class SlackNotifier implements Notifier {
readonly provider = 'slack';
private readonly fetchImpl: typeof fetch;
private readonly apiBase: string;
constructor(private readonly options: SlackNotifierOptions) {
this.fetchImpl = options.fetchImpl ?? fetch;
this.apiBase = options.apiBase ?? 'https://slack.com/api';
}
async send(
envelope: NotificationEnvelope,
signal?: AbortSignal,
): Promise<NotificationReceipt> {
let response: Response;
try {
response = await this.fetchImpl(`${this.apiBase}/chat.postMessage`, {
method: 'POST',
headers: {
authorization: `Bearer ${this.options.botToken}`,
'content-type': 'application/json; charset=utf-8',
},
body: JSON.stringify({
channel: envelope.destination,
text: formatSlackNotification(envelope.notification),
client_msg_id: slackClientMessageId(envelope.idempotencyKey),
unfurl_links: false,
unfurl_media: false,
}),
signal,
});
} catch {
throw new NotificationDeliveryError('network_error', true);
}
if (response.status === 429) {
const retrySeconds = Number(response.headers.get('retry-after'));
throw new NotificationDeliveryError(
'rate_limited',
true,
Number.isFinite(retrySeconds) && retrySeconds > 0 ? retrySeconds * 1_000 : undefined,
);
}
if (response.status >= 500) {
throw new NotificationDeliveryError('slack_unavailable', true);
}
if (!response.ok) {
throw new NotificationDeliveryError('slack_http_error', false);
}
let result: SlackResponse;
try {
result = (await response.json()) as SlackResponse;
} catch {
throw new NotificationDeliveryError('invalid_slack_response', true);
}
if (!result.ok) {
const code = normaliseSlackError(result.error);
throw new NotificationDeliveryError(code, isRetryableSlackError(code));
}
return { externalId: result.ts ?? null };
}
}
export function slackClientMessageId(idempotencyKey: string): string {
const digest = createHash('sha256').update(idempotencyKey).digest('hex');
return `${digest.slice(0, 8)}-${digest.slice(8, 12)}-4${digest.slice(13, 16)}-a${digest.slice(17, 20)}-${digest.slice(20, 32)}`;
}
function formatSlackNotification(notification: Notification): string {
if (notification.kind === 'stage_change') {
return [
`*${notification.dealName}* moved from \`${notification.fromStage}\` to \`${notification.toStage}\`.`,
`${notification.dealSide === 'demand' ? 'Demand' : 'Supply'} pipeline stage changed in PIG.`,
].join('\n');
}
return [
`*Idle capacity: ${notification.commitmentName}*`,
`${notification.gpuType} has ${formatNumber(notification.idleGpuHours)} unsold GPU-hours (${Math.round(notification.utilisation * 100)}% utilised).`,
`Idle committed cost: ${formatMoney(notification.idleCostCents)}.`,
].join('\n');
}
function formatNumber(value: number): string {
return new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 }).format(value);
}
function formatMoney(cents: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 0,
}).format(cents / 100);
}
function normaliseSlackError(error: string | undefined): string {
return error && /^[a-z0-9_]+$/i.test(error) ? error : 'unknown_slack_error';
}
function isRetryableSlackError(code: string): boolean {
return ['ratelimited', 'internal_error', 'fatal_error', 'request_timeout'].includes(code);
}
+314
View File
@@ -0,0 +1,314 @@
import { inflateRawSync } from 'node:zlib';
export const MAX_IMPORT_FILE_BYTES = 5 * 1024 * 1024;
export const MAX_IMPORT_ROWS = 2_000;
export const MAX_IMPORT_COLUMNS = 100;
export const MAX_IMPORT_CELL_CHARS = 10_000;
const MAX_XLSX_ENTRIES = 256;
const MAX_XLSX_UNCOMPRESSED_BYTES = 20 * 1024 * 1024;
export interface ParsedTable {
fileName: string;
sheetName: string | null;
headers: string[];
rows: string[][];
warnings: string[];
}
export function parseTabularFile(input: {
fileName: string;
mimeType?: string;
bytes: Uint8Array;
}): ParsedTable {
if (input.bytes.byteLength === 0) throw new Error('The selected file is empty.');
if (input.bytes.byteLength > MAX_IMPORT_FILE_BYTES) {
throw new Error('Import files may not exceed 5 MB.');
}
const fileName = input.fileName.trim().slice(0, 255);
const lowerName = fileName.toLowerCase();
if (lowerName.endsWith('.csv') || input.mimeType === 'text/csv') {
const text = new TextDecoder('utf-8', { fatal: true }).decode(input.bytes);
const { headers, rows, warnings } = parseCsv(text);
return { fileName, sheetName: null, headers, rows, warnings };
}
if (
lowerName.endsWith('.xlsx') ||
input.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
) {
const { sheetName, headers, rows, warnings } = parseXlsx(input.bytes);
return { fileName, sheetName, headers, rows, warnings };
}
throw new Error('Use a UTF-8 CSV or .xlsx workbook. Legacy .xls files are not supported.');
}
export function parseCsv(source: string): Omit<ParsedTable, 'fileName' | 'sheetName'> {
if (source.includes('\0')) throw new Error('The CSV contains invalid null bytes.');
const table: string[][] = [];
let row: string[] = [];
let field = '';
let quoted = false;
const pushField = () => {
if (field.length > MAX_IMPORT_CELL_CHARS) throw new Error('A cell exceeds 10,000 characters.');
row.push(field);
field = '';
if (row.length > MAX_IMPORT_COLUMNS) throw new Error('Imports may not exceed 100 columns.');
};
const pushRow = () => {
pushField();
table.push(row);
row = [];
if (table.length > MAX_IMPORT_ROWS + 1) throw new Error('Imports may not exceed 2,000 data rows.');
};
const text = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source;
for (let index = 0; index < text.length; index += 1) {
const character = text[index]!;
if (quoted) {
if (character === '"') {
if (text[index + 1] === '"') {
field += '"';
index += 1;
} else {
quoted = false;
}
} else {
field += character;
}
continue;
}
if (character === '"' && field.length === 0) quoted = true;
else if (character === ',') pushField();
else if (character === '\n') pushRow();
else if (character === '\r' && text[index + 1] === '\n') continue;
else if (character === '\r') pushRow();
else field += character;
}
if (quoted) throw new Error('The CSV ends inside a quoted cell.');
if (field.length > 0 || row.length > 0) pushRow();
return normaliseTabularRows(table, []);
}
function parseXlsx(bytes: Uint8Array): {
sheetName: string;
headers: string[];
rows: string[][];
warnings: string[];
} {
const entries = readZip(bytes);
const workbook = readXml(entries, 'xl/workbook.xml');
const relationships = readXml(entries, 'xl/_rels/workbook.xml.rels');
rejectActiveXml(workbook);
rejectActiveXml(relationships);
const sheets = [...workbook.matchAll(/<sheet\b[^>]*>/gi)]
.map((match) => ({
name: xmlAttribute(match[0], 'name'),
relationshipId: xmlAttribute(match[0], 'r:id'),
hidden: ['hidden', 'veryHidden'].includes(xmlAttribute(match[0], 'state') ?? ''),
}))
.filter((sheet) => sheet.name && sheet.relationshipId && !sheet.hidden);
const firstSheet = sheets[0];
if (!firstSheet?.name || !firstSheet.relationshipId) {
throw new Error('The workbook has no visible worksheet.');
}
const relationship = [...relationships.matchAll(/<Relationship\b[^>]*>/gi)]
.map((match) => match[0])
.find((tag) => xmlAttribute(tag, 'Id') === firstSheet.relationshipId);
const target = relationship ? xmlAttribute(relationship, 'Target') : null;
if (!target) throw new Error('The workbook worksheet relationship is invalid.');
const sheetPath = normaliseZipPath(target.startsWith('/') ? target.slice(1) : `xl/${target}`);
const sharedStrings = entries.has('xl/sharedStrings.xml')
? parseSharedStrings(readXml(entries, 'xl/sharedStrings.xml'))
: [];
const worksheet = readXml(entries, sheetPath);
const parsed = parseWorksheetXml(worksheet, sharedStrings);
return { sheetName: decodeXml(firstSheet.name), ...parsed };
}
export function parseWorksheetXml(
worksheet: string,
sharedStrings: readonly string[] = [],
): Omit<ParsedTable, 'fileName' | 'sheetName'> {
rejectActiveXml(worksheet);
const table: string[][] = [];
const warnings: string[] = [];
let sawFormula = false;
for (const rowMatch of worksheet.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/gi)) {
const row: string[] = [];
let sequentialColumn = 0;
for (const cellMatch of rowMatch[1]!.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/gi)) {
const attributes = cellMatch[1]!;
const body = cellMatch[2]!;
const reference = xmlAttribute(attributes, 'r');
const column = reference ? columnFromReference(reference) : sequentialColumn;
if (column >= MAX_IMPORT_COLUMNS) throw new Error('Imports may not exceed 100 columns.');
const type = xmlAttribute(attributes, 't') ?? 'n';
const rawValue = body.match(/<v\b[^>]*>([\s\S]*?)<\/v>/i)?.[1] ?? '';
if (/<f\b/i.test(body)) sawFormula = true;
let value: string;
if (type === 's') value = sharedStrings[Number(rawValue)] ?? '';
else if (type === 'inlineStr') value = extractTextNodes(body);
else if (type === 'b') value = rawValue === '1' ? 'true' : 'false';
else value = decodeXml(rawValue);
if (value.length > MAX_IMPORT_CELL_CHARS) throw new Error('A cell exceeds 10,000 characters.');
row[column] = value;
sequentialColumn = column + 1;
}
if (row.some((value) => value !== undefined && value !== '')) table.push(row);
if (table.length > MAX_IMPORT_ROWS + 1) throw new Error('Imports may not exceed 2,000 data rows.');
}
if (sawFormula) {
warnings.push('Formula cells were not executed; only cached values stored in the workbook were read.');
}
return normaliseTabularRows(table, warnings);
}
export function normaliseTabularRows(
table: string[][],
warnings: string[],
): Omit<ParsedTable, 'fileName' | 'sheetName'> {
while (table.length > 0 && table.at(-1)!.every((cell) => !cell?.trim())) table.pop();
const headerRow = table.shift();
if (!headerRow) throw new Error('The file has no header row.');
let width = headerRow.length;
while (width > 0 && !headerRow[width - 1]?.trim()) width -= 1;
if (width === 0) throw new Error('The file has no named columns.');
const headers = headerRow.slice(0, width).map((header) => header.trim());
if (headers.some((header) => !header)) throw new Error('Every imported column needs a header.');
const normalised = headers.map((header) => header.toLocaleLowerCase());
if (new Set(normalised).size !== normalised.length) {
throw new Error('Column headers must be unique, ignoring letter case.');
}
const rows = table
.map((sourceRow) => Array.from({ length: width }, (_, index) => sourceRow[index] ?? ''))
.filter((sourceRow) => sourceRow.some((cell) => cell.trim() !== ''));
if (rows.length === 0) throw new Error('The file has headers but no data rows.');
if (rows.length > MAX_IMPORT_ROWS) throw new Error('Imports may not exceed 2,000 data rows.');
if (rows.some((sourceRow) => sourceRow.some((cell) => /^[=+@]/.test(cell.trim())))) {
warnings.push('Formula-like text from the source remains inert text and is never executed.');
}
return { headers, rows, warnings };
}
function readZip(bytes: Uint8Array): Map<string, Uint8Array> {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
let end = -1;
for (let offset = bytes.byteLength - 22; offset >= Math.max(0, bytes.byteLength - 65_557); offset -= 1) {
if (view.getUint32(offset, true) === 0x06054b50) {
end = offset;
break;
}
}
if (end < 0) throw new Error('The .xlsx ZIP directory is invalid.');
const count = view.getUint16(end + 10, true);
const centralOffset = view.getUint32(end + 16, true);
if (count > MAX_XLSX_ENTRIES || count === 0xffff || centralOffset === 0xffffffff) {
throw new Error('The workbook archive is too large or uses unsupported ZIP64 metadata.');
}
const entries = new Map<string, Uint8Array>();
let offset = centralOffset;
let totalUncompressed = 0;
for (let index = 0; index < count; index += 1) {
if (offset + 46 > bytes.byteLength || view.getUint32(offset, true) !== 0x02014b50) {
throw new Error('The .xlsx ZIP directory is corrupt.');
}
const flags = view.getUint16(offset + 8, true);
const method = view.getUint16(offset + 10, true);
const compressedSize = view.getUint32(offset + 20, true);
const uncompressedSize = view.getUint32(offset + 24, true);
const nameLength = view.getUint16(offset + 28, true);
const extraLength = view.getUint16(offset + 30, true);
const commentLength = view.getUint16(offset + 32, true);
const localOffset = view.getUint32(offset + 42, true);
const name = new TextDecoder().decode(bytes.subarray(offset + 46, offset + 46 + nameLength));
const safeName = normaliseZipPath(name);
if ((flags & 1) !== 0) throw new Error('Encrypted workbooks are not supported.');
if (method !== 0 && method !== 8) throw new Error('The workbook uses unsupported ZIP compression.');
totalUncompressed += uncompressedSize;
if (totalUncompressed > MAX_XLSX_UNCOMPRESSED_BYTES) {
throw new Error('The expanded workbook may not exceed 20 MB.');
}
if (localOffset + 30 > bytes.byteLength || view.getUint32(localOffset, true) !== 0x04034b50) {
throw new Error('The workbook contains an invalid ZIP entry.');
}
const localNameLength = view.getUint16(localOffset + 26, true);
const localExtraLength = view.getUint16(localOffset + 28, true);
const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize);
if (compressed.byteLength !== compressedSize) throw new Error('The workbook ZIP entry is truncated.');
const output = method === 0
? Uint8Array.from(compressed)
: inflateRawSync(compressed, { maxOutputLength: MAX_XLSX_UNCOMPRESSED_BYTES });
if (output.byteLength !== uncompressedSize) throw new Error('The workbook ZIP entry size is inconsistent.');
entries.set(safeName, output);
offset += 46 + nameLength + extraLength + commentLength;
}
return entries;
}
function readXml(entries: Map<string, Uint8Array>, name: string): string {
const bytes = entries.get(name);
if (!bytes) throw new Error(`The workbook is missing ${name}.`);
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
}
function rejectActiveXml(xml: string): void {
if (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new Error('Workbook XML declarations are not allowed.');
}
function normaliseZipPath(path: string): string {
const segments: string[] = [];
for (const segment of path.replace(/\\/g, '/').split('/')) {
if (!segment || segment === '.') continue;
if (segment === '..') {
if (segments.length === 0) throw new Error('The workbook contains an unsafe ZIP path.');
segments.pop();
} else {
segments.push(segment);
}
}
return segments.join('/');
}
function xmlAttribute(tag: string, name: string): string | null {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const match = tag.match(new RegExp(`${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i'));
return match ? decodeXml(match[1] ?? match[2] ?? '') : null;
}
function parseSharedStrings(xml: string): string[] {
rejectActiveXml(xml);
return [...xml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/gi)].map((match) =>
extractTextNodes(match[1]!),
);
}
function extractTextNodes(xml: string): string {
return [...xml.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/gi)]
.map((match) => decodeXml(match[1]!))
.join('');
}
function decodeXml(value: string): string {
return value.replace(/&(?:#x[\da-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (entity) => {
if (entity === '&amp;') return '&';
if (entity === '&lt;') return '<';
if (entity === '&gt;') return '>';
if (entity === '&quot;') return '"';
if (entity === '&apos;') return "'";
const numeric = entity.startsWith('&#x')
? Number.parseInt(entity.slice(3, -1), 16)
: Number.parseInt(entity.slice(2, -1), 10);
return Number.isFinite(numeric) ? String.fromCodePoint(numeric) : entity;
});
}
function columnFromReference(reference: string): number {
const letters = reference.match(/^[A-Za-z]+/)?.[0];
if (!letters) throw new Error('The worksheet contains an invalid cell reference.');
let column = 0;
for (const letter of letters.toUpperCase()) column = column * 26 + letter.charCodeAt(0) - 64;
return column - 1;
}