This commit is contained in:
@@ -0,0 +1,355 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import test, { after } from 'node:test';
|
||||
import { and, eq, inArray } from 'drizzle-orm';
|
||||
import {
|
||||
accounts,
|
||||
activities,
|
||||
allocations,
|
||||
capacityCommitments,
|
||||
createDatabase,
|
||||
demandDeals,
|
||||
invites,
|
||||
teamMemberships,
|
||||
users,
|
||||
} from '@pig/db';
|
||||
import { createApp } from '../src/app';
|
||||
import { loadConfig } from '../src/lib/config';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
if (!databaseUrl) throw new Error('DATABASE_URL is required for the critical-path E2E test.');
|
||||
|
||||
const db = createDatabase({ url: databaseUrl, max: 4 });
|
||||
|
||||
after(async () => {
|
||||
await db.$client.end();
|
||||
});
|
||||
|
||||
test('invite-bound member creates, sells and observes capacity through authenticated HTTP', async () => {
|
||||
const suffix = randomUUID();
|
||||
const email = `e2e-${suffix}@example.test`;
|
||||
const subject = randomUUID();
|
||||
const accessToken = `e2e-token-${suffix}`;
|
||||
const inviteCode = `invite-${suffix}`;
|
||||
const startsAt = new Date('2027-01-01T00:00:00.000Z');
|
||||
const endsAt = new Date('2027-01-01T10:00:00.000Z');
|
||||
let inviteId: string | undefined;
|
||||
let userId: string | undefined;
|
||||
let supplyAccountId: string | undefined;
|
||||
let demandAccountId: string | undefined;
|
||||
let demandDealId: string | undefined;
|
||||
let commitmentId: string | undefined;
|
||||
let allocationId: string | undefined;
|
||||
|
||||
const config = loadConfig({
|
||||
...process.env,
|
||||
NODE_ENV: 'production',
|
||||
DATABASE_URL: databaseUrl,
|
||||
PIG_PUBLIC_URL: 'https://pig-e2e.invalid',
|
||||
SUPABASE_URL: 'https://identity-e2e.invalid',
|
||||
SUPABASE_ANON_KEY: 'e2e-anon-key',
|
||||
SUPABASE_SERVICE_KEY: '',
|
||||
PIG_ADMIN_EMAILS: '',
|
||||
PIGGY_ENABLED: 'false',
|
||||
});
|
||||
const authProvider = {
|
||||
async verifyAccessToken(token: string) {
|
||||
if (token !== accessToken) throw new Error('Invalid E2E token.');
|
||||
return { subject, email };
|
||||
},
|
||||
};
|
||||
const app = createApp(config, db, authProvider);
|
||||
|
||||
try {
|
||||
const [invite] = await db
|
||||
.insert(invites)
|
||||
.values({
|
||||
codeHash: createHash('sha256').update(inviteCode).digest('hex'),
|
||||
email,
|
||||
usesRemaining: 1,
|
||||
})
|
||||
.returning();
|
||||
assert.ok(invite);
|
||||
inviteId = invite.id;
|
||||
|
||||
// A valid external identity is authentication, not workspace membership.
|
||||
const ungatedSignup = await request(app, '/api/signup', {
|
||||
token: accessToken,
|
||||
body: { name: 'E2E Capacity Seller', team: 'supply' },
|
||||
});
|
||||
assert.equal(ungatedSignup.status, 403);
|
||||
assert.equal(ungatedSignup.body.code, 'invite_required');
|
||||
const uninvitedUsers = await db.select().from(users).where(eq(users.email, email));
|
||||
assert.equal(uninvitedUsers.length, 0);
|
||||
|
||||
// The password registration route also stays closed without its provider
|
||||
// administration credential; the injected verifier is not a bypass.
|
||||
const openRegistration = await request(app, '/api/register', {
|
||||
body: {
|
||||
email,
|
||||
password: 'not-a-production-password',
|
||||
name: 'E2E Capacity Seller',
|
||||
team: 'supply',
|
||||
inviteCode,
|
||||
},
|
||||
});
|
||||
assert.equal(openRegistration.status, 503);
|
||||
assert.equal(openRegistration.body.code, 'registration_unavailable');
|
||||
|
||||
const signup = await request(app, '/api/signup', {
|
||||
token: accessToken,
|
||||
body: {
|
||||
name: 'E2E Capacity Seller',
|
||||
team: 'supply',
|
||||
title: 'Capacity lead',
|
||||
inviteCode,
|
||||
},
|
||||
});
|
||||
assert.equal(signup.status, 201);
|
||||
const signupUser = record(signup.body.user, 'signup user');
|
||||
userId = text(signupUser.id, 'signup user id');
|
||||
assert.equal(signupUser.email, email);
|
||||
assert.equal(signupUser.isPlatformAdmin, false);
|
||||
|
||||
const [consumedInvite] = await db
|
||||
.select()
|
||||
.from(invites)
|
||||
.where(eq(invites.id, inviteId));
|
||||
assert.ok(consumedInvite);
|
||||
assert.equal(consumedInvite.usesRemaining, 0);
|
||||
assert.equal(consumedInvite.redeemedByUserId, userId);
|
||||
|
||||
// Fixture setup grants only the two capabilities this path needs. Product
|
||||
// writes below still pass through the real authorization middleware.
|
||||
await db
|
||||
.update(teamMemberships)
|
||||
.set({ role: 'lead' })
|
||||
.where(
|
||||
and(
|
||||
eq(teamMemberships.userId, userId),
|
||||
eq(teamMemberships.team, 'supply'),
|
||||
),
|
||||
);
|
||||
await db.insert(teamMemberships).values({
|
||||
userId,
|
||||
team: 'demand',
|
||||
role: 'member',
|
||||
isPrimary: false,
|
||||
});
|
||||
|
||||
const me = await request(app, '/api/me', { token: accessToken, method: 'GET' });
|
||||
assert.equal(me.status, 200);
|
||||
assert.equal(me.body.id, userId);
|
||||
assert.equal(me.body.via, 'jwt');
|
||||
const grants = array(me.body.permissions, 'permissions');
|
||||
assert.ok(
|
||||
grants.some(
|
||||
(grant) =>
|
||||
record(grant, 'permission').capability === 'commitment:write' &&
|
||||
record(grant, 'permission').team === 'supply',
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
grants.some(
|
||||
(grant) =>
|
||||
record(grant, 'permission').capability === 'deal:write' &&
|
||||
record(grant, 'permission').team === 'demand',
|
||||
),
|
||||
);
|
||||
|
||||
const [supplyAccount, demandAccount] = await Promise.all([
|
||||
db
|
||||
.insert(accounts)
|
||||
.values({ name: `E2E Supply ${suffix}`, side: 'supply' })
|
||||
.returning()
|
||||
.then(([row]) => row),
|
||||
db
|
||||
.insert(accounts)
|
||||
.values({ name: `E2E Demand ${suffix}`, side: 'demand' })
|
||||
.returning()
|
||||
.then(([row]) => row),
|
||||
]);
|
||||
assert.ok(supplyAccount);
|
||||
assert.ok(demandAccount);
|
||||
supplyAccountId = supplyAccount.id;
|
||||
demandAccountId = demandAccount.id;
|
||||
|
||||
const [deal] = await db
|
||||
.insert(demandDeals)
|
||||
.values({
|
||||
accountId: demandAccount.id,
|
||||
name: `E2E Reserved Cluster ${suffix}`,
|
||||
stage: 'deployment',
|
||||
productLine: 'compute_reserved',
|
||||
ownerUserId: userId,
|
||||
})
|
||||
.returning();
|
||||
assert.ok(deal);
|
||||
demandDealId = deal.id;
|
||||
|
||||
const baselineMarginResponse = await request(app, '/api/capacity/margin', {
|
||||
token: accessToken,
|
||||
method: 'GET',
|
||||
});
|
||||
assert.equal(baselineMarginResponse.status, 200);
|
||||
const baselineMargin = marginTotals(baselineMarginResponse.body);
|
||||
|
||||
const commitmentResponse = await request(app, '/api/commitments', {
|
||||
token: accessToken,
|
||||
body: {
|
||||
accountId: supplyAccount.id,
|
||||
name: `E2E Eight-GPU Block ${suffix}`,
|
||||
gpuType: 'H100_80GB',
|
||||
gpuCount: 8,
|
||||
interconnectType: 'Unknown',
|
||||
securityTier: 'secure_cloud',
|
||||
startsAt: startsAt.toISOString(),
|
||||
endsAt: endsAt.toISOString(),
|
||||
totalGpuHours: 80,
|
||||
costPerGpuHourCents: 100,
|
||||
},
|
||||
});
|
||||
assert.equal(commitmentResponse.status, 200);
|
||||
commitmentId = text(commitmentResponse.body.id, 'commitment id');
|
||||
|
||||
const availabilityBefore = await request(app, '/api/capacity/availability', {
|
||||
token: accessToken,
|
||||
method: 'GET',
|
||||
});
|
||||
assert.equal(availabilityBefore.status, 200);
|
||||
const capacityBefore = findById(
|
||||
array(availabilityBefore.body, 'availability'),
|
||||
'commitmentId',
|
||||
commitmentId,
|
||||
);
|
||||
assert.equal(capacityBefore.totalGpuHours, 80);
|
||||
assert.equal(capacityBefore.soldGpuHours, 0);
|
||||
assert.equal(capacityBefore.availableGpuHours, 80);
|
||||
|
||||
const marginBeforeResponse = await request(app, '/api/capacity/margin', {
|
||||
token: accessToken,
|
||||
method: 'GET',
|
||||
});
|
||||
assert.equal(marginBeforeResponse.status, 200);
|
||||
const marginBefore = marginTotals(marginBeforeResponse.body);
|
||||
assert.equal(marginBefore.revenueCents - baselineMargin.revenueCents, 0);
|
||||
assert.equal(marginBefore.costCents - baselineMargin.costCents, 8_000);
|
||||
assert.equal(marginBefore.grossMarginCents - baselineMargin.grossMarginCents, -8_000);
|
||||
|
||||
const allocationResponse = await request(app, '/api/allocations', {
|
||||
token: accessToken,
|
||||
body: {
|
||||
capacityCommitmentId: commitmentId,
|
||||
demandDealId: deal.id,
|
||||
gpuHours: 40,
|
||||
pricePerGpuHourCents: 300,
|
||||
startsAt: startsAt.toISOString(),
|
||||
endsAt: endsAt.toISOString(),
|
||||
status: 'committed',
|
||||
},
|
||||
});
|
||||
assert.equal(allocationResponse.status, 200);
|
||||
allocationId = text(allocationResponse.body.id, 'allocation id');
|
||||
|
||||
const availabilityAfter = await request(app, '/api/capacity/availability', {
|
||||
token: accessToken,
|
||||
method: 'GET',
|
||||
});
|
||||
const capacityAfter = findById(
|
||||
array(availabilityAfter.body, 'availability'),
|
||||
'commitmentId',
|
||||
commitmentId,
|
||||
);
|
||||
assert.equal(capacityAfter.soldGpuHours, 40);
|
||||
assert.equal(capacityAfter.heldGpuHours, 0);
|
||||
assert.equal(capacityAfter.availableGpuHours, 40);
|
||||
assert.equal(capacityAfter.utilisation, 0.5);
|
||||
|
||||
const marginAfterResponse = await request(app, '/api/capacity/margin', {
|
||||
token: accessToken,
|
||||
method: 'GET',
|
||||
});
|
||||
assert.equal(marginAfterResponse.status, 200);
|
||||
const marginAfter = marginTotals(marginAfterResponse.body);
|
||||
assert.equal(marginAfter.revenueCents - marginBefore.revenueCents, 12_000);
|
||||
// The full block remains the cost basis even though only half was sold.
|
||||
assert.equal(marginAfter.costCents - marginBefore.costCents, 0);
|
||||
assert.equal(marginAfter.grossMarginCents - marginBefore.grossMarginCents, 12_000);
|
||||
assert.equal(marginAfter.grossMarginCents - baselineMargin.grossMarginCents, 4_000);
|
||||
} finally {
|
||||
if (allocationId) await db.delete(allocations).where(eq(allocations.id, allocationId));
|
||||
if (commitmentId) {
|
||||
await db.delete(capacityCommitments).where(eq(capacityCommitments.id, commitmentId));
|
||||
}
|
||||
if (demandDealId) await db.delete(demandDeals).where(eq(demandDeals.id, demandDealId));
|
||||
if (userId) await db.delete(activities).where(eq(activities.actorUserId, userId));
|
||||
const accountIds = [supplyAccountId, demandAccountId].filter(
|
||||
(id): id is string => id !== undefined,
|
||||
);
|
||||
if (accountIds.length) await db.delete(accounts).where(inArray(accounts.id, accountIds));
|
||||
if (inviteId) await db.delete(invites).where(eq(invites.id, inviteId));
|
||||
if (userId) await db.delete(users).where(eq(users.id, userId));
|
||||
}
|
||||
});
|
||||
|
||||
async function request(
|
||||
app: ReturnType<typeof createApp>,
|
||||
path: string,
|
||||
options: { token?: string; body?: unknown; method?: 'GET' | 'POST' } = {},
|
||||
): Promise<{ status: number; body: Record<string, unknown> }> {
|
||||
const response = await app.request(path, {
|
||||
method: options.method ?? 'POST',
|
||||
headers: {
|
||||
...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
|
||||
...(options.body === undefined ? {} : { 'content-type': 'application/json' }),
|
||||
},
|
||||
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
body: (await response.json()) as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
assert.ok(value && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`);
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
assert.ok(Array.isArray(value), `${label} must be an array`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
assert.fail(`${label} must be a string`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function findById(rows: unknown[], field: string, id: string): Record<string, unknown> {
|
||||
const found = rows.map((row) => record(row, 'row')).find((row) => row[field] === id);
|
||||
assert.ok(found, `Expected ${field}=${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function marginTotals(body: Record<string, unknown>): {
|
||||
revenueCents: number;
|
||||
costCents: number;
|
||||
grossMarginCents: number;
|
||||
} {
|
||||
const totals = record(body.totals, 'margin totals');
|
||||
return {
|
||||
revenueCents: number(totals.revenueCents, 'margin revenue'),
|
||||
costCents: number(totals.costCents, 'margin cost'),
|
||||
grossMarginCents: number(totals.grossMarginCents, 'gross margin'),
|
||||
};
|
||||
}
|
||||
|
||||
function number(value: unknown, label: string): number {
|
||||
if (typeof value !== 'number') {
|
||||
assert.fail(`${label} must be a number`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -9,16 +9,19 @@
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"start": "tsx src/server.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test --import tsx test/*.test.ts"
|
||||
"test": "node --test --import tsx test/*.test.ts",
|
||||
"test:e2e": "node --test --import tsx e2e/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@noble/curves": "^1.9.7",
|
||||
"@pig/core": "*",
|
||||
"@pig/db": "*",
|
||||
"@pig/prime": "*",
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"hono": "^4.6.14",
|
||||
"drizzle-orm": "^0.38.3",
|
||||
"hono": "^4.6.14",
|
||||
"jose": "^5.9.6",
|
||||
"nostr-tools": "^2.24.1",
|
||||
"zod": "^3.24.1"
|
||||
}
|
||||
}
|
||||
|
||||
+72
-102
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 & {
|
||||
/** 0–1. 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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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, "\\'");
|
||||
}
|
||||
@@ -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 1–100 columns.', 400);
|
||||
}
|
||||
if (input.rows.length === 0 || input.rows.length > MAX_IMPORT_ROWS) {
|
||||
throw new MutationError('invalid_import', 'Imports need 1–2,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');
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 === '&') return '&';
|
||||
if (entity === '<') return '<';
|
||||
if (entity === '>') return '>';
|
||||
if (entity === '"') return '"';
|
||||
if (entity === ''') 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;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Config } from '../src/lib/config';
|
||||
import {
|
||||
inviteMetadata,
|
||||
isInferenceEndpoint,
|
||||
memberAccessSchema,
|
||||
platformSettingsResponse,
|
||||
platformSettingsSchema,
|
||||
} from '../src/routes/admin-settings';
|
||||
import { decryptSecret, encryptSecret, SecretConfigurationError } from '../src/lib/secrets';
|
||||
|
||||
describe('admin settings decisions', () => {
|
||||
it('keeps inference separate from the Prime compute API host', () => {
|
||||
assert.equal(isInferenceEndpoint('https://api.pinference.ai/api/v1'), true);
|
||||
assert.equal(isInferenceEndpoint('https://api.primeintellect.ai'), false);
|
||||
assert.equal(
|
||||
platformSettingsSchema.safeParse({
|
||||
piggyInferenceBase: 'https://api.primeintellect.ai/v1',
|
||||
}).success,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('encrypts credentials with authenticated random envelopes and requires an external key', () => {
|
||||
const key = randomBytes(32).toString('base64');
|
||||
const first = encryptSecret('prime-secret', key);
|
||||
const second = encryptSecret('prime-secret', key);
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(decryptSecret(first, key), 'prime-secret');
|
||||
assert.throws(
|
||||
() => encryptSecret('prime-secret', undefined),
|
||||
(error: unknown) => error instanceof SecretConfigurationError,
|
||||
);
|
||||
});
|
||||
|
||||
it('never returns a stored key, ciphertext, or invite hash in metadata', () => {
|
||||
const now = new Date('2026-08-12T12:00:00.000Z');
|
||||
const settings = platformSettingsResponse(
|
||||
{
|
||||
id: 'default',
|
||||
piggyModel: 'nvidia/nemotron-3-nano-30b-a3b',
|
||||
piggyInferenceBase: 'https://api.pinference.ai/api/v1',
|
||||
piggyEnabled: true,
|
||||
primeApiKeyEncrypted: 'v1.iv.tag.ciphertext',
|
||||
primeApiKeyUpdatedAt: now,
|
||||
primeSyncEnabled: true,
|
||||
primeSyncIntervalMinutes: 30,
|
||||
updatedByUserId: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
PRIME_API_KEY: 'environment-secret',
|
||||
PRIME_API_BASE: 'https://api.primeintellect.ai',
|
||||
} as Config,
|
||||
);
|
||||
assert.equal(JSON.stringify(settings).includes('ciphertext'), false);
|
||||
assert.equal(JSON.stringify(settings).includes('environment-secret'), false);
|
||||
|
||||
const invite = inviteMetadata({
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
codeHash: 'never-return-this',
|
||||
email: null,
|
||||
team: null,
|
||||
role: 'member',
|
||||
createdByUserId: null,
|
||||
expiresAt: null,
|
||||
usesRemaining: 1,
|
||||
scopeNote: null,
|
||||
redeemedByUserId: null,
|
||||
redeemedAt: null,
|
||||
revokedAt: null,
|
||||
createdAt: now,
|
||||
});
|
||||
assert.equal('codeHash' in invite, false);
|
||||
});
|
||||
|
||||
it('rejects duplicate team assignments rather than depending on a database conflict', () => {
|
||||
assert.equal(
|
||||
memberAccessSchema.safeParse({
|
||||
isPlatformAdmin: false,
|
||||
memberships: [
|
||||
{ team: 'supply', role: 'member' },
|
||||
{ team: 'supply', role: 'admin' },
|
||||
],
|
||||
}).success,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Principal } from '../src/lib/auth';
|
||||
import {
|
||||
assertApiKeyActive,
|
||||
AuthError,
|
||||
hashApiKey,
|
||||
requireCapability,
|
||||
} from '../src/lib/auth';
|
||||
import {
|
||||
apiKeyCreationResponse,
|
||||
apiKeyCreateSchema,
|
||||
apiKeyMetadata,
|
||||
generateApiKey,
|
||||
resolveApiKeyTarget,
|
||||
} from '../src/routes/api-keys';
|
||||
|
||||
const userId = '00000000-0000-0000-0000-000000000001';
|
||||
const otherUserId = '00000000-0000-0000-0000-000000000002';
|
||||
|
||||
function principal(overrides: Partial<Principal> = {}): Principal {
|
||||
return {
|
||||
userId,
|
||||
email: 'seller@example.com',
|
||||
name: 'Seller',
|
||||
isPlatformAdmin: false,
|
||||
teams: [{ team: 'demand', role: 'member' }],
|
||||
via: 'jwt',
|
||||
scopes: ['read', 'write'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function storedKey(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: '00000000-0000-0000-0000-000000000010',
|
||||
userId,
|
||||
name: 'Codex',
|
||||
keyHash: 'stored-hash',
|
||||
keyPrefix: 'pig_abc123',
|
||||
scopes: ['read'],
|
||||
lastUsedAt: null,
|
||||
expiresAt: null,
|
||||
revokedAt: null,
|
||||
createdAt: new Date('2026-08-12T12:00:00.000Z'),
|
||||
...overrides,
|
||||
} as Parameters<typeof apiKeyMetadata>[0];
|
||||
}
|
||||
|
||||
describe('API key lifecycle decisions', () => {
|
||||
it('stores only a SHA-256 hash and returns plaintext only from creation', () => {
|
||||
const generated = generateApiKey();
|
||||
assert.match(generated.key, /^pig_[A-Za-z0-9_-]{43}$/);
|
||||
assert.equal(generated.keyHash, hashApiKey(generated.key));
|
||||
assert.equal(generated.keyHash.length, 64);
|
||||
assert.equal(generated.keyPrefix, generated.key.slice(0, 10));
|
||||
|
||||
const row = storedKey({ keyHash: generated.keyHash, keyPrefix: generated.keyPrefix });
|
||||
const created = apiKeyCreationResponse(row, generated.key);
|
||||
const listed = apiKeyMetadata(row);
|
||||
assert.equal(created.key, generated.key);
|
||||
assert.equal('key' in listed, false);
|
||||
assert.equal('keyHash' in listed, false);
|
||||
});
|
||||
|
||||
it('allows read-only or read/write keys, never write-only keys', () => {
|
||||
assert.deepEqual(apiKeyCreateSchema.parse({ name: 'Reader' }).scopes, ['read']);
|
||||
assert.deepEqual(
|
||||
apiKeyCreateSchema.parse({ name: 'Writer', scopes: ['read', 'write'] }).scopes,
|
||||
['read', 'write'],
|
||||
);
|
||||
assert.equal(apiKeyCreateSchema.safeParse({ name: 'Writer', scopes: ['write'] }).success, false);
|
||||
assert.equal(
|
||||
apiKeyCreateSchema.safeParse({ name: 'Duplicate', scopes: ['read', 'read'] }).success,
|
||||
false,
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => requireCapability(principal({ via: 'api_key', scopes: ['read'] }), 'deal:write', 'demand'),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_scope',
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
requireCapability(
|
||||
principal({ via: 'api_key', scopes: ['read', 'write'] }),
|
||||
'deal:write',
|
||||
'demand',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a revoked key immediately while leaving an active key usable', () => {
|
||||
const now = new Date('2026-08-12T12:00:00.000Z');
|
||||
assert.doesNotThrow(() => assertApiKeyActive({ revokedAt: null, expiresAt: null }, now));
|
||||
assert.throws(
|
||||
() => assertApiKeyActive({ revokedAt: new Date('2026-08-12T11:59:00.000Z'), expiresAt: null }, now),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'revoked_key',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps self-service personal, with explicit platform-admin cross-user access', () => {
|
||||
assert.equal(resolveApiKeyTarget(principal()), userId);
|
||||
assert.throws(
|
||||
() => resolveApiKeyTarget(principal(), otherUserId),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
||||
);
|
||||
assert.equal(
|
||||
resolveApiKeyTarget(principal({ isPlatformAdmin: true }), otherUserId),
|
||||
otherUserId,
|
||||
);
|
||||
assert.throws(
|
||||
() => resolveApiKeyTarget(principal({ via: 'api_key' })),
|
||||
(error: unknown) =>
|
||||
error instanceof AuthError && error.code === 'credential_management_forbidden',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Tests for the identity-provider boundary.
|
||||
*
|
||||
* These cases pin the trust decisions shared by protected requests and profile
|
||||
* creation. Membership remains deliberately outside this module.
|
||||
*/
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { generateKeyPairSync, type KeyObject } from 'node:crypto';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { after, before, describe, it } from 'node:test';
|
||||
import { exportJWK, SignJWT } from 'jose';
|
||||
import {
|
||||
createSupabaseAuthProvider,
|
||||
type AuthProvider,
|
||||
} from '../src/lib/auth-provider';
|
||||
|
||||
describe('Supabase auth provider', () => {
|
||||
let server: Server;
|
||||
let provider: AuthProvider;
|
||||
let issuer: string;
|
||||
let privateKey: KeyObject;
|
||||
|
||||
before(async () => {
|
||||
const keys = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
privateKey = keys.privateKey;
|
||||
const publicJwk = await exportJWK(keys.publicKey);
|
||||
|
||||
server = createServer((request, response) => {
|
||||
if (request.url !== '/auth/v1/.well-known/jwks.json') {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
response.setHeader('content-type', 'application/json');
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
keys: [{ ...publicJwk, alg: 'RS256', kid: 'test-key', use: 'sig' }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', resolve);
|
||||
});
|
||||
|
||||
const address = server.address() as AddressInfo;
|
||||
const supabaseUrl = `http://127.0.0.1:${address.port}`;
|
||||
issuer = `${supabaseUrl}/auth/v1`;
|
||||
provider = createSupabaseAuthProvider(supabaseUrl);
|
||||
});
|
||||
|
||||
after(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
);
|
||||
|
||||
async function sign(claims: Record<string, unknown>, tokenIssuer = issuer): Promise<string> {
|
||||
return new SignJWT(claims)
|
||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
||||
.setIssuer(tokenIssuer)
|
||||
.setExpirationTime('5m')
|
||||
.sign(privateKey);
|
||||
}
|
||||
|
||||
it('returns only the verified external identity claims', async () => {
|
||||
const token = await sign({ sub: 'provider-user-1', email: 'Owner@Example.com' });
|
||||
|
||||
assert.deepEqual(await provider.verifyAccessToken(token), {
|
||||
subject: 'provider-user-1',
|
||||
email: 'Owner@Example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a correctly signed token issued for a different identity provider', async () => {
|
||||
// Signature validity alone is insufficient: without the issuer check, a
|
||||
// sibling deployment using the same key could authenticate here.
|
||||
const token = await sign({ sub: 'provider-user-1' }, 'https://other.example/auth/v1');
|
||||
|
||||
await assert.rejects(provider.verifyAccessToken(token));
|
||||
});
|
||||
|
||||
it('accepts a subject without email for protected requests', async () => {
|
||||
// Existing members are joined by subject. Email is required only by the
|
||||
// invite-gated profile flow, not as an extra condition on every request.
|
||||
const token = await sign({ sub: 'provider-user-2' });
|
||||
|
||||
assert.deepEqual(await provider.verifyAccessToken(token), {
|
||||
subject: 'provider-user-2',
|
||||
email: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a token with no stable subject', async () => {
|
||||
const token = await sign({ email: 'owner@example.com' });
|
||||
|
||||
await assert.rejects(provider.verifyAccessToken(token));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Principal } from '../src/lib/auth';
|
||||
import { AuthError, effectivePermissions, requireCapability } from '../src/lib/auth';
|
||||
|
||||
function principal(overrides: Partial<Principal> = {}): Principal {
|
||||
return {
|
||||
userId: '00000000-0000-0000-0000-000000000001',
|
||||
email: 'seller@example.com',
|
||||
name: 'Seller',
|
||||
isPlatformAdmin: false,
|
||||
teams: [{ team: 'demand', role: 'member' }],
|
||||
via: 'jwt',
|
||||
scopes: ['read', 'write'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('capability enforcement', () => {
|
||||
it('rejects a role grant from the wrong team', () => {
|
||||
assert.throws(
|
||||
() => requireCapability(principal(), 'deal:write', 'supply'),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
||||
);
|
||||
});
|
||||
|
||||
it('removes write grants from a read-only API key', () => {
|
||||
const readOnly = principal({ via: 'api_key', scopes: ['read'] });
|
||||
|
||||
assert.deepEqual(effectivePermissions(readOnly), []);
|
||||
assert.throws(
|
||||
() => requireCapability(readOnly, 'deal:write', 'demand'),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_scope',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { describe, it } from 'node:test';
|
||||
import { schnorr } from '@noble/curves/secp256k1.js';
|
||||
import { getPublicKey, nip19, verifyEvent, type Event } from 'nostr-tools';
|
||||
import { buzzWorkspaceId } from '../src/routes/buzz';
|
||||
import {
|
||||
BuzzNotifier,
|
||||
decodeBuzzAuthorization,
|
||||
normaliseBuzzRelayUrl,
|
||||
parseAndVerifyBuzzAuthTag,
|
||||
} from '../src/services/buzz';
|
||||
import { NotificationDeliveryError } from '../src/services/notifier';
|
||||
import { loadConfig } from '../src/lib/config';
|
||||
|
||||
const AGENT_KEY = `${'0'.repeat(63)}1`;
|
||||
const CHANNEL = '10000000-0000-4000-8000-000000000001';
|
||||
const envelope = {
|
||||
idempotencyKey: 'buzz:link:stage-event',
|
||||
destination: CHANNEL,
|
||||
workspaceId: 'buzz.example.com',
|
||||
notification: {
|
||||
kind: 'stage_change' as const,
|
||||
accountId: '20000000-0000-4000-8000-000000000002',
|
||||
dealId: '30000000-0000-4000-8000-000000000003',
|
||||
dealSide: 'demand' as const,
|
||||
dealName: 'Reserved H100 cluster',
|
||||
fromStage: 'proposal',
|
||||
toStage: 'procurement',
|
||||
changedAt: '2026-08-13T12:00:00.000Z',
|
||||
},
|
||||
};
|
||||
|
||||
describe('Buzz relay identity', () => {
|
||||
it('normalises the WebSocket URL used by Buzz clients into its HTTP bridge community', () => {
|
||||
assert.equal(normaliseBuzzRelayUrl('wss://buzz.example.com/'), 'https://buzz.example.com');
|
||||
assert.equal(buzzWorkspaceId('wss://buzz.example.com/'), 'buzz.example.com');
|
||||
});
|
||||
|
||||
it('accepts official hex and nsec representations as the same identity', () => {
|
||||
const nsec = nip19.nsecEncode(Uint8Array.from(Buffer.from(AGENT_KEY, 'hex')));
|
||||
const fromHex = new BuzzNotifier({ relayUrl: 'https://buzz.example.com', privateKey: AGENT_KEY });
|
||||
const fromNsec = new BuzzNotifier({ relayUrl: 'https://buzz.example.com', privateKey: nsec });
|
||||
assert.equal(fromHex.workspaceId, fromNsec.workspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Buzz configuration', () => {
|
||||
const base = { DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig', NODE_ENV: 'test' };
|
||||
|
||||
it('requires relay and private identity together', () => {
|
||||
assert.throws(
|
||||
() => loadConfig({ ...base, BUZZ_RELAY_URL: 'https://buzz.example.com' }),
|
||||
/BUZZ_PRIVATE_KEY/,
|
||||
);
|
||||
assert.throws(
|
||||
() => loadConfig({ ...base, BUZZ_PRIVATE_KEY: AGENT_KEY }),
|
||||
/BUZZ_RELAY_URL/,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the Buzz private identity in server configuration only', () => {
|
||||
const config = loadConfig({
|
||||
...base,
|
||||
BUZZ_RELAY_URL: 'https://buzz.example.com',
|
||||
BUZZ_PRIVATE_KEY: AGENT_KEY,
|
||||
});
|
||||
assert.equal(config.BUZZ_RELAY_URL, 'https://buzz.example.com');
|
||||
assert.equal(config.BUZZ_PRIVATE_KEY, AGENT_KEY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Buzz signed delivery', () => {
|
||||
it('reuses the signed message event id while refreshing NIP-98 replay nonces', async () => {
|
||||
const requests: { event: Event; auth: Event }[] = [];
|
||||
let nonce = 0;
|
||||
const notifier = new BuzzNotifier({
|
||||
relayUrl: 'https://buzz.example.com',
|
||||
privateKey: AGENT_KEY,
|
||||
now: () => new Date('2026-08-13T12:00:01.000Z'),
|
||||
nonce: () => `nonce-${++nonce}`,
|
||||
fetchImpl: async (_input, init) => {
|
||||
const event = JSON.parse(String(init?.body)) as Event;
|
||||
const headers = new Headers(init?.headers);
|
||||
const auth = decodeBuzzAuthorization(headers.get('authorization') ?? '');
|
||||
assert.ok(auth);
|
||||
requests.push({ event, auth });
|
||||
return Response.json({ event_id: event.id, accepted: true, message: '' });
|
||||
},
|
||||
});
|
||||
|
||||
await notifier.send(envelope);
|
||||
await notifier.send(envelope);
|
||||
await notifier.send({ ...envelope, idempotencyKey: 'buzz:link:different-event' });
|
||||
|
||||
assert.equal(requests[0]?.event.id, requests[1]?.event.id);
|
||||
assert.notEqual(requests[0]?.event.id, requests[2]?.event.id);
|
||||
assert.notEqual(requests[0]?.auth.id, requests[1]?.auth.id);
|
||||
assert.equal(requests[0]?.event.kind, 9);
|
||||
assert.ok(verifyEvent(requests[0]!.event));
|
||||
assert.deepEqual(requests[0]?.event.tags[0], ['h', CHANNEL]);
|
||||
const payloadTag = requests[0]?.auth.tags.find((tag) => tag[0] === 'payload');
|
||||
assert.equal(
|
||||
payloadTag?.[1],
|
||||
createHash('sha256').update(JSON.stringify(requests[0]!.event)).digest('hex'),
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses to route a link from another relay community', async () => {
|
||||
let called = false;
|
||||
const notifier = new BuzzNotifier({
|
||||
relayUrl: 'https://buzz.example.com',
|
||||
privateKey: AGENT_KEY,
|
||||
fetchImpl: async () => {
|
||||
called = true;
|
||||
return Response.json({});
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
notifier.send({ ...envelope, workspaceId: 'other.example.com' }),
|
||||
(error: unknown) =>
|
||||
error instanceof NotificationDeliveryError &&
|
||||
error.code === 'buzz_workspace_mismatch' &&
|
||||
!error.retryable,
|
||||
);
|
||||
assert.equal(called, false);
|
||||
});
|
||||
|
||||
it('marks relay throttling retryable without exposing response content', async () => {
|
||||
const notifier = new BuzzNotifier({
|
||||
relayUrl: 'https://buzz.example.com',
|
||||
privateKey: AGENT_KEY,
|
||||
fetchImpl: async () =>
|
||||
new Response('{"error":"rate-limited: private detail"}', {
|
||||
status: 429,
|
||||
headers: { 'retry-after': '9' },
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
notifier.send(envelope),
|
||||
(error: unknown) =>
|
||||
error instanceof NotificationDeliveryError &&
|
||||
error.code === 'buzz_rate_limited' &&
|
||||
error.retryable &&
|
||||
error.retryAfterMs === 9_000 &&
|
||||
!error.message.includes('private detail'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Buzz owner attestation', () => {
|
||||
it('verifies NIP-OA against the configured agent before sending it', () => {
|
||||
const ownerKey = Uint8Array.from(Buffer.from(`${'0'.repeat(63)}2`, 'hex'));
|
||||
const agentPublicKey = getPublicKey(Uint8Array.from(Buffer.from(AGENT_KEY, 'hex')));
|
||||
const conditions = 'kind=9';
|
||||
const digest = createHash('sha256')
|
||||
.update(`nostr:agent-auth:${agentPublicKey}:${conditions}`)
|
||||
.digest();
|
||||
const tag = JSON.stringify([
|
||||
'auth',
|
||||
getPublicKey(ownerKey),
|
||||
conditions,
|
||||
Buffer.from(schnorr.sign(digest, ownerKey)).toString('hex'),
|
||||
]);
|
||||
assert.deepEqual(parseAndVerifyBuzzAuthTag(tag, agentPublicKey), JSON.parse(tag));
|
||||
const tampered = JSON.parse(tag) as string[];
|
||||
tampered[3] = `${tampered[3]![0] === '0' ? '1' : '0'}${tampered[3]!.slice(1)}`;
|
||||
assert.throws(
|
||||
() => parseAndVerifyBuzzAuthTag(JSON.stringify(tampered), agentPublicKey),
|
||||
/BUZZ_AUTH_TAG/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SecurityTier } from '@pig/core';
|
||||
import {
|
||||
capacityMeetsRequirement,
|
||||
type AvailabilityRow,
|
||||
} from '../src/services/capacity';
|
||||
|
||||
function capacity(securityTier: SecurityTier): AvailabilityRow {
|
||||
return {
|
||||
commitmentId: '10000000-0000-4000-8000-000000000001',
|
||||
accountId: '20000000-0000-4000-8000-000000000001',
|
||||
name: `${securityTier} block`,
|
||||
gpuType: 'H100_80GB',
|
||||
gpuCount: 8,
|
||||
interconnectType: 'Infiniband',
|
||||
securityTier,
|
||||
startsAt: new Date('2026-01-01T00:00:00Z'),
|
||||
endsAt: new Date('2027-01-01T00:00:00Z'),
|
||||
totalGpuHours: 10_000,
|
||||
soldGpuHours: 0,
|
||||
heldGpuHours: 0,
|
||||
availableGpuHours: 10_000,
|
||||
costPerGpuHourCents: 100,
|
||||
utilisation: 0,
|
||||
breakEvenPriceCents: 100,
|
||||
};
|
||||
}
|
||||
|
||||
test('government demand excludes both community and ordinary secure cloud', () => {
|
||||
const requirement = { gpuCount: 1, minSecurityTier: 'government' as const };
|
||||
assert.equal(capacityMeetsRequirement(capacity('community_cloud'), requirement), false);
|
||||
assert.equal(capacityMeetsRequirement(capacity('secure_cloud'), requirement), false);
|
||||
assert.equal(capacityMeetsRequirement(capacity('government'), requirement), true);
|
||||
});
|
||||
|
||||
test('higher classifications may satisfy lower requirements', () => {
|
||||
assert.equal(
|
||||
capacityMeetsRequirement(capacity('government'), {
|
||||
gpuCount: 1,
|
||||
minSecurityTier: 'secure_cloud',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
capacityMeetsRequirement(capacity('secure_cloud'), {
|
||||
gpuCount: 1,
|
||||
minSecurityTier: 'community_cloud',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('security sufficiency never overrides the export-control predicate', () => {
|
||||
const block = capacity('government');
|
||||
assert.equal(
|
||||
capacityMeetsRequirement(block, {
|
||||
gpuCount: 1,
|
||||
minSecurityTier: 'government',
|
||||
complianceDecision: { decision: 'block', supersededAt: null },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
capacityMeetsRequirement(block, {
|
||||
gpuCount: 1,
|
||||
minSecurityTier: 'government',
|
||||
complianceDecision: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
capacityMeetsRequirement(block, {
|
||||
gpuCount: 1,
|
||||
minSecurityTier: 'government',
|
||||
complianceDecision: { decision: 'allow', supersededAt: new Date() },
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
capacityMeetsRequirement(block, {
|
||||
gpuCount: 1,
|
||||
minSecurityTier: 'government',
|
||||
complianceDecision: { decision: 'allow', supersededAt: null },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Allocation, Database } from '@pig/db';
|
||||
import type { Principal } from '../src/lib/auth';
|
||||
import { executeMutation } from '../src/lib/mutation';
|
||||
import { createAllocationMutationDefinition } from '../src/routes/capacity-writes';
|
||||
import {
|
||||
findCapacityViolation,
|
||||
type CommitmentCapacity,
|
||||
type ReservationCapacity,
|
||||
} from '../src/services/capacity-writes';
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
const START = new Date('2026-01-01T00:00:00.000Z');
|
||||
|
||||
function at(hour: number): Date {
|
||||
return new Date(START.getTime() + hour * HOUR);
|
||||
}
|
||||
|
||||
function commitment(overrides: Partial<CommitmentCapacity> = {}): CommitmentCapacity {
|
||||
return {
|
||||
id: '10000000-0000-4000-8000-000000000001',
|
||||
gpuCount: 8,
|
||||
startsAt: at(0),
|
||||
endsAt: at(10),
|
||||
totalGpuHours: 80,
|
||||
shape: null,
|
||||
oversubscriptionPct: 0,
|
||||
terminatedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function reservation(overrides: Partial<ReservationCapacity> = {}): ReservationCapacity {
|
||||
return {
|
||||
gpuHours: 80,
|
||||
startsAt: at(0),
|
||||
endsAt: at(10),
|
||||
status: 'committed',
|
||||
holdExpiresAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('allocation availability invariant', () => {
|
||||
it('permits only the oversubscription explicitly recorded on the commitment', () => {
|
||||
const existing = reservation();
|
||||
const extra = reservation({ gpuHours: 20 });
|
||||
|
||||
assert.equal(
|
||||
findCapacityViolation(
|
||||
commitment({ totalGpuHours: 100, oversubscriptionPct: 25 }),
|
||||
[existing],
|
||||
extra,
|
||||
at(-1),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
findCapacityViolation(commitment({ totalGpuHours: 100 }), [existing], extra, at(-1))?.code,
|
||||
'shape_capacity_exceeded',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores expired holds but live holds still reserve capacity', () => {
|
||||
const expired = reservation({
|
||||
status: 'planned',
|
||||
holdExpiresAt: at(-1),
|
||||
});
|
||||
const live = reservation({
|
||||
status: 'planned',
|
||||
holdExpiresAt: at(1),
|
||||
});
|
||||
const requested = reservation();
|
||||
|
||||
assert.equal(findCapacityViolation(commitment(), [expired], requested, at(0)), null);
|
||||
assert.equal(
|
||||
findCapacityViolation(commitment(), [live], requested, at(0))?.code,
|
||||
'total_capacity_exceeded',
|
||||
);
|
||||
});
|
||||
|
||||
it('checks each authoritative shape interval instead of averaging the term', () => {
|
||||
const shaped = commitment({
|
||||
endsAt: at(20),
|
||||
totalGpuHours: 120,
|
||||
shape: {
|
||||
intervals: [at(0).toISOString(), at(10).toISOString(), at(20).toISOString()],
|
||||
quantities: [8, 4],
|
||||
},
|
||||
});
|
||||
const firstTranche = reservation({ gpuHours: 60 });
|
||||
const overlapsRamp = reservation({ gpuHours: 30 });
|
||||
const fitsLaterTranche = reservation({
|
||||
gpuHours: 40,
|
||||
startsAt: at(10),
|
||||
endsAt: at(20),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
findCapacityViolation(shaped, [firstTranche], overlapsRamp, at(-1))?.code,
|
||||
'shape_capacity_exceeded',
|
||||
);
|
||||
assert.equal(findCapacityViolation(shaped, [firstTranche], fitsLaterTranche, at(-1)), null);
|
||||
});
|
||||
});
|
||||
|
||||
const principal: Principal = {
|
||||
userId: '00000000-0000-4000-8000-000000000001',
|
||||
email: 'seller@example.com',
|
||||
name: 'Seller',
|
||||
isPlatformAdmin: false,
|
||||
teams: [{ team: 'demand', role: 'member' }],
|
||||
via: 'jwt',
|
||||
scopes: ['read', 'write'],
|
||||
};
|
||||
|
||||
describe('allocation mutation transaction', () => {
|
||||
it('passes the mutation transaction through the capacity check and audit write', async () => {
|
||||
const events: string[] = [];
|
||||
const tx = {
|
||||
insert: () => ({
|
||||
values: async () => {
|
||||
events.push('activity');
|
||||
},
|
||||
}),
|
||||
};
|
||||
const db = {
|
||||
transaction: async (work: (transaction: unknown) => Promise<unknown>) => {
|
||||
events.push('begin');
|
||||
const result = await work(tx);
|
||||
events.push('commit');
|
||||
return result;
|
||||
},
|
||||
} as unknown as Database;
|
||||
|
||||
const definition = createAllocationMutationDefinition((transaction) => {
|
||||
assert.equal(transaction, tx);
|
||||
return {
|
||||
createAllocation: async (input) => {
|
||||
events.push('lock-check-insert');
|
||||
return {
|
||||
allocation: {
|
||||
id: '30000000-0000-4000-8000-000000000003',
|
||||
capacityCommitmentId: input.capacityCommitmentId,
|
||||
demandDealId: input.demandDealId,
|
||||
gpuHours: String(input.gpuHours),
|
||||
pricePerGpuHourCents: input.pricePerGpuHourCents,
|
||||
status: input.status,
|
||||
} as Allocation,
|
||||
commitment: {
|
||||
id: input.capacityCommitmentId,
|
||||
name: 'Eight H100s',
|
||||
},
|
||||
deal: {
|
||||
id: input.demandDealId,
|
||||
accountId: '40000000-0000-4000-8000-000000000004',
|
||||
},
|
||||
};
|
||||
},
|
||||
createCommitment: async () => assert.fail('wrong mutation'),
|
||||
updateCommitment: async () => assert.fail('wrong mutation'),
|
||||
createHold: async () => assert.fail('wrong mutation'),
|
||||
releaseAllocation: async () => assert.fail('wrong mutation'),
|
||||
};
|
||||
});
|
||||
|
||||
await executeMutation(
|
||||
db,
|
||||
principal,
|
||||
async () => ({
|
||||
capacityCommitmentId: '10000000-0000-4000-8000-000000000001',
|
||||
demandDealId: '20000000-0000-4000-8000-000000000002',
|
||||
gpuHours: 8,
|
||||
pricePerGpuHourCents: 225,
|
||||
startsAt: '2026-01-01T00:00:00.000Z',
|
||||
endsAt: '2026-01-01T01:00:00.000Z',
|
||||
status: 'committed',
|
||||
}),
|
||||
definition,
|
||||
);
|
||||
|
||||
assert.deepEqual(events, ['begin', 'lock-check-insert', 'activity', 'commit']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Contract, SlaTerm } from '@pig/db';
|
||||
import {
|
||||
renewalAlarm,
|
||||
resolveContractPrecedence,
|
||||
validateParentRelationship,
|
||||
} from '../src/services/contracts';
|
||||
|
||||
function contract(overrides: Partial<Contract>): Contract {
|
||||
return {
|
||||
id: '10000000-0000-4000-8000-000000000001',
|
||||
accountId: '20000000-0000-4000-8000-000000000002',
|
||||
type: 'msa',
|
||||
status: 'executed',
|
||||
side: 'demand',
|
||||
title: 'Master agreement',
|
||||
externalReference: null,
|
||||
demandDealId: null,
|
||||
supplyDealId: null,
|
||||
capacityCommitmentId: null,
|
||||
parentContractId: null,
|
||||
contractingPartyName: null,
|
||||
takeOrPayFloorPct: null,
|
||||
prepaidPct: null,
|
||||
terminationTier: null,
|
||||
assignableOnDefault: false,
|
||||
assignmentDeadlineBusinessDays: null,
|
||||
effectiveAt: null,
|
||||
expiresAt: null,
|
||||
executedAt: null,
|
||||
terminatedAt: null,
|
||||
isAutoRenew: false,
|
||||
noticeDays: null,
|
||||
valueCents: null,
|
||||
currency: 'USD',
|
||||
governingLaw: null,
|
||||
documentUrl: null,
|
||||
ownerUserId: null,
|
||||
notes: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function sla(overrides: Partial<SlaTerm>): SlaTerm {
|
||||
return {
|
||||
id: '30000000-0000-4000-8000-000000000003',
|
||||
contractId: '10000000-0000-4000-8000-000000000001',
|
||||
kind: 'negotiated',
|
||||
uptimeTargetPct: null,
|
||||
nodeReplacementHours: null,
|
||||
mttrHours: null,
|
||||
supportResponseHours: null,
|
||||
measurementWindow: 'monthly',
|
||||
measurementUnit: 'cluster',
|
||||
remedyType: 'service_credit',
|
||||
abatementTriggerValue: null,
|
||||
abatementTriggerUnit: null,
|
||||
claimDeadlineValue: null,
|
||||
claimDeadlineUnit: 'days',
|
||||
creditExpiryMonths: null,
|
||||
isSoleRemedy: true,
|
||||
sparePoolObligation: null,
|
||||
sparePoolScope: [],
|
||||
maintenanceClasses: [],
|
||||
reasonableEndeavoursDaysPerYear: null,
|
||||
rcaDeliveryHours: null,
|
||||
creditSchedule: [],
|
||||
creditCapPct: null,
|
||||
exclusions: null,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('contract precedence', () => {
|
||||
it('uses explicit order-form terms before the MSA without copying inherited values', () => {
|
||||
const master = contract({ takeOrPayFloorPct: '70.00', prepaidPct: '25.00' });
|
||||
const order = contract({
|
||||
id: '10000000-0000-4000-8000-000000000004',
|
||||
type: 'order_form',
|
||||
title: 'Order form',
|
||||
parentContractId: master.id,
|
||||
takeOrPayFloorPct: '90.00',
|
||||
});
|
||||
const resolved = resolveContractPrecedence(order.id, [master, order], [
|
||||
sla({ uptimeTargetPct: '99.900' }),
|
||||
]);
|
||||
|
||||
const floor = resolved.contract.takeOrPayFloorPct;
|
||||
const prepaid = resolved.contract.prepaidPct;
|
||||
const uptime = resolved.sla.uptimeTargetPct;
|
||||
assert.ok(floor);
|
||||
assert.ok(prepaid);
|
||||
assert.ok(uptime);
|
||||
|
||||
assert.equal(floor.value, '90.00');
|
||||
assert.equal(floor.inherited, false);
|
||||
assert.equal(prepaid.value, '25.00');
|
||||
assert.equal(prepaid.inherited, true);
|
||||
assert.equal(uptime.sourceContractId, master.id);
|
||||
});
|
||||
|
||||
it('treats false, zero and an empty list as deliberate child overrides', () => {
|
||||
const master = contract({ assignableOnDefault: true });
|
||||
const child = contract({
|
||||
id: '10000000-0000-4000-8000-000000000004',
|
||||
parentContractId: master.id,
|
||||
assignableOnDefault: false,
|
||||
});
|
||||
const resolved = resolveContractPrecedence(child.id, [master, child], [
|
||||
sla({ sparePoolScope: ['compute nodes', 'network switches'] }),
|
||||
sla({
|
||||
id: '30000000-0000-4000-8000-000000000004',
|
||||
contractId: child.id,
|
||||
reasonableEndeavoursDaysPerYear: 0,
|
||||
sparePoolScope: [],
|
||||
}),
|
||||
]);
|
||||
|
||||
const assignable = resolved.contract.assignableOnDefault;
|
||||
const reasonableEndeavours = resolved.sla.reasonableEndeavoursDaysPerYear;
|
||||
const sparePoolScope = resolved.sla.sparePoolScope;
|
||||
assert.ok(assignable);
|
||||
assert.ok(reasonableEndeavours);
|
||||
assert.ok(sparePoolScope);
|
||||
|
||||
assert.equal(assignable.value, false);
|
||||
assert.equal(reasonableEndeavours.value, 0);
|
||||
assert.deepEqual(sparePoolScope.value, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contract alarms and hierarchy', () => {
|
||||
it('raises the renewal alarm at the negotiated notice deadline, not at expiry', () => {
|
||||
const result = renewalAlarm(
|
||||
{
|
||||
isAutoRenew: true,
|
||||
expiresAt: new Date('2026-04-01T00:00:00.000Z'),
|
||||
noticeDays: 60,
|
||||
},
|
||||
new Date('2026-02-15T00:00:00.000Z'),
|
||||
);
|
||||
assert.equal(result.renewalNoticeAt?.toISOString(), '2026-01-31T00:00:00.000Z');
|
||||
assert.equal(result.renewalState, 'due');
|
||||
});
|
||||
|
||||
it('rejects cross-account parentage even when both records otherwise look valid', () => {
|
||||
const child = contract({ id: '10000000-0000-4000-8000-000000000004' });
|
||||
const parent = contract({ accountId: '20000000-0000-4000-8000-000000000009' });
|
||||
assert.equal(
|
||||
validateParentRelationship(child, parent, []),
|
||||
'Parent and child contracts must belong to the same account.',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Database } from '@pig/db';
|
||||
import { facts } from '@pig/db';
|
||||
import type { Principal } from '../src/lib/auth';
|
||||
import { executeMutation, MutationError } from '../src/lib/mutation';
|
||||
import { factDecisionDefinition } from '../src/routes/facts';
|
||||
|
||||
const reviewer: Principal = {
|
||||
userId: '00000000-0000-0000-0000-000000000001',
|
||||
email: 'research@example.com',
|
||||
name: 'Research reviewer',
|
||||
isPlatformAdmin: false,
|
||||
teams: [{ team: 'research', role: 'admin' }],
|
||||
via: 'jwt',
|
||||
scopes: ['read', 'write'],
|
||||
};
|
||||
|
||||
const proposedFact = {
|
||||
id: '10000000-0000-0000-0000-000000000001',
|
||||
accountId: '20000000-0000-0000-0000-000000000001',
|
||||
contactId: null,
|
||||
field: 'supplierType',
|
||||
value: 'neocloud',
|
||||
score: '0.780',
|
||||
band: 'probable',
|
||||
status: 'proposed',
|
||||
evidence: { excerpt: 'Operates dedicated GPU cloud regions.' },
|
||||
sourceUrl: 'https://example.com/infrastructure',
|
||||
method: 'web_search',
|
||||
agentRunId: null,
|
||||
decidedByUserId: null,
|
||||
decidedAt: null,
|
||||
observedAt: new Date('2026-08-12T10:00:00Z'),
|
||||
supersededAt: null,
|
||||
createdAt: new Date('2026-08-12T10:00:00Z'),
|
||||
} as const;
|
||||
|
||||
function fakeDatabase(initial: Record<string, unknown>) {
|
||||
let stored = { ...initial };
|
||||
const updates: { table: unknown; values: Record<string, unknown> }[] = [];
|
||||
const activities: Record<string, unknown>[] = [];
|
||||
|
||||
const tx = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({ limit: async () => [stored] }),
|
||||
}),
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: () => ({
|
||||
returning: async () => {
|
||||
updates.push({ table, values });
|
||||
stored = { ...stored, ...values };
|
||||
return [stored];
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: async (row: Record<string, unknown>) => {
|
||||
activities.push(row);
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const db = {
|
||||
transaction: async (work: (transaction: unknown) => Promise<unknown>) => work(tx),
|
||||
} as unknown as Database;
|
||||
|
||||
return { db, updates, activities };
|
||||
}
|
||||
|
||||
describe('fact review decisions', () => {
|
||||
it('approves evidence without applying an arbitrary field to the CRM record', async () => {
|
||||
const state = fakeDatabase(proposedFact);
|
||||
|
||||
const result = await executeMutation(
|
||||
state.db,
|
||||
reviewer,
|
||||
async () => ({ status: 'approved' }),
|
||||
factDecisionDefinition,
|
||||
{ id: proposedFact.id },
|
||||
);
|
||||
|
||||
assert.equal(result.fact.status, 'approved');
|
||||
assert.equal(result.recordUpdated, false);
|
||||
assert.equal(state.updates.length, 1);
|
||||
assert.equal(state.updates[0]?.table, facts);
|
||||
assert.deepEqual(state.updates[0]?.values, {
|
||||
status: 'approved',
|
||||
decidedByUserId: reviewer.userId,
|
||||
decidedAt: state.updates[0]?.values.decidedAt,
|
||||
});
|
||||
assert.ok(state.updates[0]?.values.decidedAt instanceof Date);
|
||||
assert.deepEqual(state.activities[0]?.meta, {
|
||||
factId: proposedFact.id,
|
||||
decision: 'approved',
|
||||
field: proposedFact.field,
|
||||
recordUpdated: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses to approve an unsupported claim', async () => {
|
||||
const state = fakeDatabase({
|
||||
...proposedFact,
|
||||
evidence: null,
|
||||
sourceUrl: null,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(
|
||||
state.db,
|
||||
reviewer,
|
||||
async () => ({ status: 'approved' }),
|
||||
factDecisionDefinition,
|
||||
{ id: proposedFact.id },
|
||||
),
|
||||
(error: unknown) =>
|
||||
error instanceof MutationError && error.code === 'missing_evidence',
|
||||
);
|
||||
assert.deepEqual(state.updates, []);
|
||||
assert.deepEqual(state.activities, []);
|
||||
});
|
||||
|
||||
it('allows an unsupported proposal to be dismissed without manufacturing evidence', async () => {
|
||||
const state = fakeDatabase({
|
||||
...proposedFact,
|
||||
evidence: null,
|
||||
sourceUrl: null,
|
||||
});
|
||||
|
||||
const result = await executeMutation(
|
||||
state.db,
|
||||
reviewer,
|
||||
async () => ({ status: 'dismissed' }),
|
||||
factDecisionDefinition,
|
||||
{ id: proposedFact.id },
|
||||
);
|
||||
|
||||
assert.equal(result.fact.status, 'dismissed');
|
||||
assert.equal(result.recordUpdated, false);
|
||||
assert.equal(state.updates.length, 1);
|
||||
assert.equal(state.updates[0]?.table, facts);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import { GOOGLE_OAUTH_SCOPES } from '../src/services/google-sheets';
|
||||
import {
|
||||
buildGoogleAuthorizationUrl,
|
||||
googleConnectionMetadata,
|
||||
normaliseGoogleValues,
|
||||
oauthFlowMatches,
|
||||
oauthStateHash,
|
||||
parseBoundedGoogleRange,
|
||||
} from '../src/services/google-sheets';
|
||||
|
||||
describe('Google OAuth proof and redaction', () => {
|
||||
it('binds state and PKCE without putting the verifier in the authorization URL', () => {
|
||||
const state = 'state-secret';
|
||||
const verifier = 'verifier-secret';
|
||||
const url = new URL(buildGoogleAuthorizationUrl({
|
||||
clientId: 'client-id',
|
||||
redirectUri: 'https://pig.example/oauth/google/callback',
|
||||
state,
|
||||
challenge: 'challenge',
|
||||
}));
|
||||
assert.equal(url.searchParams.get('state'), state);
|
||||
assert.equal(url.searchParams.get('code_challenge'), 'challenge');
|
||||
assert.equal(url.searchParams.get('code_challenge_method'), 'S256');
|
||||
assert.equal(url.searchParams.get('scope'), GOOGLE_OAUTH_SCOPES.join(' '));
|
||||
assert.equal(url.searchParams.get('access_type'), 'offline');
|
||||
assert.equal(url.toString().includes(verifier), false);
|
||||
});
|
||||
|
||||
it('accepts only the matching, unexpired, just-consumed state', () => {
|
||||
const now = new Date('2026-08-13T12:00:00.000Z');
|
||||
const flow = {
|
||||
stateHash: oauthStateHash('expected'),
|
||||
browserBindingHash: oauthStateHash('browser'),
|
||||
expiresAt: new Date('2026-08-13T12:01:00.000Z'),
|
||||
consumedAt: now,
|
||||
};
|
||||
assert.equal(oauthFlowMatches(flow, 'expected', 'browser', now), true);
|
||||
assert.equal(oauthFlowMatches(flow, 'attacker', 'browser', now), false);
|
||||
assert.equal(oauthFlowMatches(flow, 'expected', 'other-browser', now), false);
|
||||
assert.equal(oauthFlowMatches({ ...flow, consumedAt: null }, 'expected', 'browser', now), false);
|
||||
assert.equal(oauthFlowMatches({ ...flow, expiresAt: now }, 'expected', 'browser', now), false);
|
||||
});
|
||||
|
||||
it('never serializes encrypted tokens in connection metadata', () => {
|
||||
const metadata = googleConnectionMetadata(true, {
|
||||
userId: '00000000-0000-4000-8000-000000000001',
|
||||
refreshTokenEncrypted: 'v1.refresh.secret',
|
||||
accessTokenEncrypted: 'v1.access.secret',
|
||||
accessTokenExpiresAt: new Date(),
|
||||
scopes: [...GOOGLE_OAUTH_SCOPES],
|
||||
connectedAt: new Date('2026-08-13T12:00:00.000Z'),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
const serialized = JSON.stringify(metadata);
|
||||
assert.equal(serialized.includes('v1.refresh.secret'), false);
|
||||
assert.equal(serialized.includes('v1.access.secret'), false);
|
||||
assert.deepEqual(Object.keys(metadata), ['configured', 'connected', 'connectedAt', 'scopes']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Google Sheets range and value boundaries', () => {
|
||||
it('requires an explicit rectangular range within A14 limits and the selected grid', () => {
|
||||
assert.deepEqual(parseBoundedGoogleRange('a1:CV2001', { rowCount: 3_000, columnCount: 100 }), {
|
||||
a1: 'A1:CV2001',
|
||||
rows: 2_001,
|
||||
columns: 100,
|
||||
});
|
||||
assert.throws(() => parseBoundedGoogleRange('A:Z'));
|
||||
assert.throws(() => parseBoundedGoogleRange('A1:C2002'));
|
||||
assert.throws(() => parseBoundedGoogleRange('A1:C10', { rowCount: 9, columnCount: 3 }));
|
||||
});
|
||||
|
||||
it('normalizes formatted values into A14 rows while keeping text inert', () => {
|
||||
const table = normaliseGoogleValues([
|
||||
['external_id', 'name', 'active', 'score'],
|
||||
[7, '=IMPORTDATA("https://example.test")', true, 2.5],
|
||||
], 4);
|
||||
assert.deepEqual(table.headers, ['external_id', 'name', 'active', 'score']);
|
||||
assert.deepEqual(table.rows, [['7', '=IMPORTDATA("https://example.test")', 'true', '2.5']]);
|
||||
assert.match(table.warnings.join(' '), /formula source was not imported/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import { ACCOUNT_SIDES } from '@pig/core';
|
||||
import type { Database } from '@pig/db';
|
||||
import type { Principal } from '../src/lib/auth';
|
||||
import { executeMutation } from '../src/lib/mutation';
|
||||
import { createImportCommitMutationDefinition } from '../src/routes/imports';
|
||||
import { convertImportRow, findDuplicateImportKeys } from '../src/services/imports';
|
||||
import { parseCsv, parseWorksheetXml } from '../src/services/tabular-import';
|
||||
|
||||
const principal: Principal = {
|
||||
userId: '00000000-0000-4000-8000-000000000001',
|
||||
email: 'admin@example.com',
|
||||
name: 'Import admin',
|
||||
isPlatformAdmin: false,
|
||||
teams: [{ team: 'demand', role: 'admin' }],
|
||||
via: 'jwt',
|
||||
scopes: ['read', 'write'],
|
||||
};
|
||||
|
||||
describe('untrusted tabular parsing', () => {
|
||||
it('keeps multiline CSV and formula-like cells as inert text', () => {
|
||||
const parsed = parseCsv('external_id,name\n1,"Acme\nCompute"\n2,"=WEBSERVICE(""https://example.test"")"');
|
||||
assert.deepEqual(parsed.headers, ['external_id', 'name']);
|
||||
assert.equal(parsed.rows[0]?.[1], 'Acme\nCompute');
|
||||
assert.equal(parsed.rows[1]?.[1], '=WEBSERVICE("https://example.test")');
|
||||
assert.match(parsed.warnings.join(' '), /inert text/);
|
||||
});
|
||||
|
||||
it('does not execute XLSX formulas or follow formula URLs', () => {
|
||||
const parsed = parseWorksheetXml(
|
||||
'<worksheet><sheetData><row>' +
|
||||
'<c r="A1" t="inlineStr"><is><t>external_id</t></is></c>' +
|
||||
'<c r="B1" t="inlineStr"><is><t>score</t></is></c>' +
|
||||
'</row><row><c r="A2"><v>1</v></c>' +
|
||||
'<c r="B2"><f>WEBSERVICE("https://example.test")</f><v>7</v></c>' +
|
||||
'</row></sheetData></worksheet>',
|
||||
);
|
||||
assert.deepEqual(parsed.rows, [['1', '7']]);
|
||||
assert.match(parsed.warnings.join(' '), /not executed/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('import row decisions', () => {
|
||||
it('rejects duplicate user-selected source identities', () => {
|
||||
assert.deepEqual([...findDuplicateImportKeys(['vendor-1', 'vendor-2', 'vendor-1'])], ['vendor-1']);
|
||||
});
|
||||
|
||||
it('validates ontology values from core rather than accepting invented sides', () => {
|
||||
const converted = convertImportRow(
|
||||
'account',
|
||||
['external_id', 'name', 'side'],
|
||||
['vendor-1', 'Acme', 'marketplace'],
|
||||
{ name: 'name', side: 'side' },
|
||||
true,
|
||||
);
|
||||
assert.equal(converted.values.name, 'Acme');
|
||||
const message = converted.errors[0]?.message ?? '';
|
||||
assert.match(message, /must be one of/);
|
||||
for (const side of ACCOUNT_SIDES) assert.ok(message.includes(side));
|
||||
});
|
||||
});
|
||||
|
||||
describe('import commit mutation', () => {
|
||||
it('commits imported records and their audit evidence in one transaction', async () => {
|
||||
const events: string[] = [];
|
||||
const tx = {
|
||||
insert: () => ({ values: async () => events.push('activity') }),
|
||||
};
|
||||
const db = {
|
||||
transaction: async (work: (transaction: unknown) => Promise<unknown>) => {
|
||||
events.push('begin');
|
||||
const result = await work(tx);
|
||||
events.push('commit');
|
||||
return result;
|
||||
},
|
||||
} as unknown as Database;
|
||||
const definition = createImportCommitMutationDefinition((transaction) => {
|
||||
assert.equal(transaction, tx);
|
||||
return {
|
||||
commit: async () => {
|
||||
events.push('records-and-identities');
|
||||
return { created: 1, updated: 0, total: 1 };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await executeMutation(db, principal, async () => ({
|
||||
entity: 'account',
|
||||
sourceName: 'accounts.csv',
|
||||
headers: ['external_id', 'name', 'side'],
|
||||
rows: [['vendor-1', 'Acme', 'demand']],
|
||||
mapping: { name: 'name', side: 'side' },
|
||||
keySourceColumn: 'external_id',
|
||||
previewDigest: 'a'.repeat(64),
|
||||
}), definition);
|
||||
|
||||
assert.deepEqual(events, ['begin', 'records-and-identities', 'activity', 'commit']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import type { Principal } from '../src/lib/auth';
|
||||
import { AuthError } from '../src/lib/auth';
|
||||
import { loadConfig } from '../src/lib/config';
|
||||
import type { ApiEnv } from '../src/lib/mutation';
|
||||
import {
|
||||
createIntegrationSettingsRoutes,
|
||||
integrationReadiness,
|
||||
} from '../src/routes/integration-settings';
|
||||
|
||||
const config = loadConfig({
|
||||
DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig',
|
||||
NODE_ENV: 'test',
|
||||
SLACK_BOT_TOKEN: 'xoxb-secret-value',
|
||||
SLACK_SIGNING_SECRET: 'slack-signing-secret',
|
||||
BUZZ_RELAY_URL: 'https://buzz.example.com',
|
||||
BUZZ_PRIVATE_KEY: 'buzz-private-secret',
|
||||
BUZZ_AUTH_TAG: '["auth","owner","kind=9","secret-signature"]',
|
||||
});
|
||||
|
||||
function principal(isPlatformAdmin: boolean): Principal {
|
||||
return {
|
||||
userId: '00000000-0000-4000-8000-000000000001',
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin',
|
||||
isPlatformAdmin,
|
||||
teams: [],
|
||||
via: 'jwt',
|
||||
scopes: ['read', 'write'],
|
||||
};
|
||||
}
|
||||
|
||||
describe('integration readiness', () => {
|
||||
it('reports readiness without serialising any credential material', () => {
|
||||
const body = JSON.stringify(integrationReadiness(config));
|
||||
assert.deepEqual(JSON.parse(body), {
|
||||
slack: {
|
||||
source: 'environment',
|
||||
configured: true,
|
||||
deliveryReady: true,
|
||||
commandsReady: true,
|
||||
},
|
||||
buzz: {
|
||||
source: 'environment',
|
||||
configured: true,
|
||||
deliveryReady: true,
|
||||
relayUrl: 'https://buzz.example.com',
|
||||
workspaceId: 'buzz.example.com',
|
||||
},
|
||||
});
|
||||
for (const secret of [
|
||||
config.SLACK_BOT_TOKEN,
|
||||
config.SLACK_SIGNING_SECRET,
|
||||
config.BUZZ_PRIVATE_KEY,
|
||||
config.BUZZ_AUTH_TAG,
|
||||
]) {
|
||||
assert.ok(secret);
|
||||
assert.equal(body.includes(secret), false);
|
||||
}
|
||||
});
|
||||
|
||||
it('authorizes before returning readiness metadata', async () => {
|
||||
const app = new Hono<ApiEnv>();
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('principal', principal(false));
|
||||
await next();
|
||||
});
|
||||
app.route('/', createIntegrationSettingsRoutes(config));
|
||||
app.onError((error, c) => {
|
||||
if (error instanceof AuthError) return c.json({ code: error.code }, error.status);
|
||||
throw error;
|
||||
});
|
||||
|
||||
const response = await app.request('/api/admin/integrations');
|
||||
assert.equal(response.status, 403);
|
||||
assert.deepEqual(await response.json(), { code: 'insufficient_permission' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import { z } from 'zod';
|
||||
import type { Database } from '@pig/db';
|
||||
import type { Principal } from '../src/lib/auth';
|
||||
import { AuthError } from '../src/lib/auth';
|
||||
import { apiError, executeMutation, MutationError } from '../src/lib/mutation';
|
||||
|
||||
const principal: Principal = {
|
||||
userId: '00000000-0000-0000-0000-000000000001',
|
||||
email: 'seller@example.com',
|
||||
name: 'Seller',
|
||||
isPlatformAdmin: false,
|
||||
teams: [{ team: 'demand', role: 'member' }],
|
||||
via: 'jwt',
|
||||
scopes: ['read', 'write'],
|
||||
};
|
||||
|
||||
function fakeDatabase(events: string[], activityRows: unknown[]): Database {
|
||||
const tx = {
|
||||
insert: () => ({
|
||||
values: async (row: unknown) => {
|
||||
events.push('activity');
|
||||
activityRows.push(row);
|
||||
},
|
||||
}),
|
||||
};
|
||||
return {
|
||||
transaction: async (work: (transaction: unknown) => Promise<unknown>) => {
|
||||
events.push('transaction');
|
||||
return work(tx);
|
||||
},
|
||||
} as unknown as Database;
|
||||
}
|
||||
|
||||
describe('mutation convention', () => {
|
||||
it('checks capability before reading attacker-controlled input', async () => {
|
||||
const events: string[] = [];
|
||||
const forbidden = { ...principal, teams: [{ team: 'supply', role: 'admin' }] } as Principal;
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(fakeDatabase(events, []), forbidden, async () => {
|
||||
events.push('body');
|
||||
return {};
|
||||
}, {
|
||||
schema: z.object({ name: z.string() }),
|
||||
permission: { capability: 'deal:write', team: 'demand' },
|
||||
invalidMessage: 'Invalid deal.',
|
||||
async mutate() {
|
||||
events.push('mutate');
|
||||
return { data: {}, activity: { type: 'note', subject: 'Changed' } };
|
||||
},
|
||||
}),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
||||
);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
it('rejects invalid ontology input before opening a transaction', async () => {
|
||||
const events: string[] = [];
|
||||
const stages = ['qualification', 'legal'] as const;
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(fakeDatabase(events, []), principal, async () => ({ stage: 'invented' }), {
|
||||
schema: z.object({ stage: z.enum(stages) }),
|
||||
permission: { capability: 'deal:write', team: 'demand' },
|
||||
invalidMessage: 'Invalid transition.',
|
||||
async mutate() {
|
||||
events.push('mutate');
|
||||
return { data: {}, activity: { type: 'stage_change', subject: 'Changed' } };
|
||||
},
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof MutationError &&
|
||||
error.code === 'invalid_request' &&
|
||||
apiError(error.code, error.message, error.issues).issues?.length === 1,
|
||||
);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
|
||||
it('writes mutation evidence in the same transaction with framework attribution', async () => {
|
||||
const events: string[] = [];
|
||||
const rows: unknown[] = [];
|
||||
const result = await executeMutation(
|
||||
fakeDatabase(events, rows),
|
||||
principal,
|
||||
async () => ({ stage: 'legal' }),
|
||||
{
|
||||
schema: z.object({ stage: z.literal('legal') }),
|
||||
permission: { capability: 'deal:write', team: 'demand' },
|
||||
invalidMessage: 'Invalid transition.',
|
||||
async mutate() {
|
||||
events.push('mutate');
|
||||
const data = { id: 'deal-1' };
|
||||
return {
|
||||
data,
|
||||
activity: {
|
||||
type: 'stage_change',
|
||||
subject: 'Moved to legal',
|
||||
demandDealId: data.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(result, { id: 'deal-1' });
|
||||
assert.deepEqual(events, ['transaction', 'mutate', 'activity']);
|
||||
assert.deepEqual(rows, [
|
||||
{
|
||||
type: 'stage_change',
|
||||
subject: 'Moved to legal',
|
||||
demandDealId: 'deal-1',
|
||||
actorUserId: principal.userId,
|
||||
actorAgent: null,
|
||||
source: 'manual',
|
||||
occurredAt: (rows[0] as { occurredAt: Date }).occurredAt,
|
||||
},
|
||||
]);
|
||||
assert.ok((rows[0] as { occurredAt: unknown }).occurredAt instanceof Date);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
collectNotionPages,
|
||||
createNotionOAuthAttempt,
|
||||
flattenNotionProperty,
|
||||
notionAuthorizationUrl,
|
||||
notionConnectionMetadata,
|
||||
verifyNotionOAuthAttempt,
|
||||
} from '../src/services/notion';
|
||||
|
||||
describe('Notion OAuth decisions', () => {
|
||||
it('uses one-time state and browser binding without inventing unsupported PKCE parameters', () => {
|
||||
let byte = 0;
|
||||
const attempt = createNotionOAuthAttempt(
|
||||
new Date('2026-08-13T12:00:00.000Z'),
|
||||
(size) => Buffer.alloc(size, byte += 1),
|
||||
);
|
||||
assert.notEqual(attempt.state, attempt.verifier);
|
||||
assert.notEqual(attempt.stateHash, attempt.state);
|
||||
assert.notEqual(attempt.verifierHash, attempt.verifier);
|
||||
assert.equal(verifyNotionOAuthAttempt(
|
||||
attempt.verifier,
|
||||
attempt.verifierHash,
|
||||
attempt.expiresAt,
|
||||
new Date('2026-08-13T12:09:59.000Z'),
|
||||
), true);
|
||||
assert.equal(verifyNotionOAuthAttempt('tampered', attempt.verifierHash, attempt.expiresAt), false);
|
||||
assert.equal(verifyNotionOAuthAttempt(
|
||||
attempt.verifier,
|
||||
attempt.verifierHash,
|
||||
attempt.expiresAt,
|
||||
new Date('2026-08-13T12:10:00.000Z'),
|
||||
), false);
|
||||
const url = new URL(notionAuthorizationUrl({
|
||||
clientId: 'client-id',
|
||||
redirectUri: 'https://pig.example/api/imports/notion/oauth/callback',
|
||||
state: attempt.state,
|
||||
}));
|
||||
assert.equal(url.searchParams.get('state'), attempt.state);
|
||||
assert.equal(url.searchParams.has('code_challenge'), false);
|
||||
assert.equal(url.searchParams.has('code_verifier'), false);
|
||||
});
|
||||
|
||||
it('redacts every credential-shaped field from connection metadata', () => {
|
||||
const storedConnection = {
|
||||
id: 'connection-id',
|
||||
workspaceId: 'workspace-id',
|
||||
workspaceName: 'Sales',
|
||||
workspaceIcon: null,
|
||||
createdAt: new Date('2026-08-13T12:00:00.000Z'),
|
||||
credentialsEncrypted: 'v1.secret.envelope',
|
||||
accessToken: 'never-return',
|
||||
};
|
||||
const metadata = notionConnectionMetadata(storedConnection);
|
||||
const serialized = JSON.stringify(metadata);
|
||||
assert.equal(serialized.includes('never-return'), false);
|
||||
assert.equal(serialized.includes('envelope'), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notion pagination and flattening decisions', () => {
|
||||
it('follows cursors in order and stops after the declared import bound', async () => {
|
||||
const cursors: Array<string | undefined> = [];
|
||||
const rows = await collectNotionPages(async (cursor) => {
|
||||
cursors.push(cursor);
|
||||
return cursor
|
||||
? { results: [{ id: '2' }, { id: '3' }], has_more: false, next_cursor: null }
|
||||
: { results: [{ id: '1' }], has_more: true, next_cursor: 'next' };
|
||||
}, 2);
|
||||
assert.deepEqual(cursors, [undefined, 'next']);
|
||||
assert.deepEqual(rows.map((row) => row.id), ['1', '2']);
|
||||
});
|
||||
|
||||
it('maps supported values explicitly and rejects unstable property types', () => {
|
||||
assert.deepEqual(flattenNotionProperty({
|
||||
type: 'title',
|
||||
title: [{ plain_text: 'Acme' }, { plain_text: ' Compute' }],
|
||||
}), { value: 'Acme Compute' });
|
||||
assert.deepEqual(flattenNotionProperty({
|
||||
type: 'date',
|
||||
date: { start: '2026-09-01', end: '2026-09-30' },
|
||||
}), { value: '2026-09-01/2026-09-30' });
|
||||
assert.deepEqual(flattenNotionProperty({
|
||||
type: 'formula',
|
||||
formula: { type: 'number', number: 12.5 },
|
||||
}), { value: '12.5' });
|
||||
assert.match(flattenNotionProperty({ type: 'button', button: {} }).error ?? '', /stable tabular/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import type { Principal } from '../src/lib/auth';
|
||||
import type { ApiEnv } from '../src/lib/mutation';
|
||||
import { createPiggyChatRoutes } from '../src/routes/piggy-chat';
|
||||
|
||||
const principal: Principal = {
|
||||
userId: '10000000-0000-4000-8000-000000000001',
|
||||
email: 'member@example.com',
|
||||
name: 'Member',
|
||||
isPlatformAdmin: false,
|
||||
teams: [{ team: 'demand', role: 'member' }],
|
||||
via: 'jwt',
|
||||
scopes: ['read', 'write'],
|
||||
};
|
||||
|
||||
function appFor(fetchImpl: typeof fetch, identity: Principal = principal) {
|
||||
const app = new Hono<ApiEnv>();
|
||||
app.use('*', async (context, next) => {
|
||||
context.set('principal', identity);
|
||||
await next();
|
||||
});
|
||||
app.route(
|
||||
'/',
|
||||
createPiggyChatRoutes({
|
||||
enabled: true,
|
||||
internalUrl: 'http://127.0.0.1:8931',
|
||||
internalToken: 'internal-token-with-at-least-32-characters',
|
||||
fetchImpl,
|
||||
}),
|
||||
);
|
||||
return app;
|
||||
}
|
||||
|
||||
test('the authenticated proxy forwards bounded identity and relays NDJSON unchanged', async () => {
|
||||
let forwarded: Record<string, unknown> | undefined;
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
assert.equal(String(input), 'http://127.0.0.1:8931/internal/chat');
|
||||
assert.equal(
|
||||
new Headers(init?.headers).get('authorization'),
|
||||
'Bearer internal-token-with-at-least-32-characters',
|
||||
);
|
||||
forwarded = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||
return new Response(
|
||||
`${JSON.stringify({ type: 'content_delta', delta: 'Scoped answer' })}\n` +
|
||||
`${JSON.stringify({ type: 'done', inputTokens: 2, outputTokens: 3 })}\n`,
|
||||
{ status: 200, headers: { 'content-type': 'application/x-ndjson' } },
|
||||
);
|
||||
};
|
||||
const app = appFor(fetchImpl);
|
||||
const response = await app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: 'Summarise this contract.',
|
||||
context: {
|
||||
type: 'contract',
|
||||
id: '20000000-0000-4000-8000-000000000002',
|
||||
label: 'Order form',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get('content-type') ?? '', /application\/x-ndjson/);
|
||||
assert.deepEqual(forwarded, {
|
||||
principalUserId: principal.userId,
|
||||
message: 'Summarise this contract.',
|
||||
context: {
|
||||
type: 'contract',
|
||||
id: '20000000-0000-4000-8000-000000000002',
|
||||
label: 'Order form',
|
||||
},
|
||||
});
|
||||
assert.equal(
|
||||
await response.text(),
|
||||
`${JSON.stringify({ type: 'content_delta', delta: 'Scoped answer' })}\n` +
|
||||
`${JSON.stringify({ type: 'done', inputTokens: 2, outputTokens: 3 })}\n`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a credential without read scope never reaches the internal service', async () => {
|
||||
let fetched = false;
|
||||
const app = appFor(
|
||||
async () => {
|
||||
fetched = true;
|
||||
return new Response();
|
||||
},
|
||||
{ ...principal, scopes: ['write'] },
|
||||
);
|
||||
const response = await app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Read the book.' }),
|
||||
});
|
||||
assert.equal(response.status, 403);
|
||||
assert.equal(fetched, false);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Database } from '@pig/db';
|
||||
import { accounts, activities, agentTasks } from '@pig/db';
|
||||
import type { Principal } from '../src/lib/auth';
|
||||
import { AuthError } from '../src/lib/auth';
|
||||
import { executeMutation, MutationError } from '../src/lib/mutation';
|
||||
import {
|
||||
accountSupportsTeam,
|
||||
createAccountMutationDefinition,
|
||||
createDemandDealMutationDefinition,
|
||||
} from '../src/routes/records';
|
||||
|
||||
const demandPrincipal: Principal = {
|
||||
userId: '00000000-0000-4000-8000-000000000001',
|
||||
email: 'seller@example.com',
|
||||
name: 'Seller',
|
||||
isPlatformAdmin: false,
|
||||
teams: [{ team: 'demand', role: 'member' }],
|
||||
via: 'jwt',
|
||||
scopes: ['read', 'write'],
|
||||
};
|
||||
|
||||
describe('record-side decisions', () => {
|
||||
it('makes dual-side accounts available to both commercial teams', () => {
|
||||
assert.equal(accountSupportsTeam('both', 'demand'), true);
|
||||
assert.equal(accountSupportsTeam('both', 'supply'), true);
|
||||
assert.equal(accountSupportsTeam('both', 'research'), false);
|
||||
assert.equal(accountSupportsTeam('supply', 'demand'), false);
|
||||
assert.equal(accountSupportsTeam('demand', 'supply'), false);
|
||||
});
|
||||
|
||||
it('does not let a demand writer create a supply-only account', async () => {
|
||||
const db = {
|
||||
transaction: async (work: (tx: unknown) => Promise<unknown>) => work({}),
|
||||
} as unknown as Database;
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(
|
||||
db,
|
||||
demandPrincipal,
|
||||
async () => ({
|
||||
name: 'Supply only',
|
||||
side: 'supply',
|
||||
}),
|
||||
createAccountMutationDefinition(),
|
||||
),
|
||||
(error: unknown) =>
|
||||
error instanceof AuthError && error.code === 'insufficient_permission',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('record mutation evidence and relationships', () => {
|
||||
it('queues enrichment and writes the audit event in the account transaction', async () => {
|
||||
const events: string[] = [];
|
||||
const created = {
|
||||
id: '10000000-0000-4000-8000-000000000001',
|
||||
name: 'Customer',
|
||||
side: 'demand',
|
||||
};
|
||||
const tx = {
|
||||
insert: (table: unknown) => ({
|
||||
values: (row: unknown) => {
|
||||
events.push(
|
||||
table === accounts ? 'account' : table === agentTasks ? 'agent-task' : table === activities ? 'activity' : 'unknown',
|
||||
);
|
||||
return { returning: async () => [created], row };
|
||||
},
|
||||
}),
|
||||
};
|
||||
const db = {
|
||||
transaction: async (work: (transaction: unknown) => Promise<unknown>) => {
|
||||
events.push('begin');
|
||||
const result = await work(tx);
|
||||
events.push('commit');
|
||||
return result;
|
||||
},
|
||||
} as unknown as Database;
|
||||
|
||||
await executeMutation(
|
||||
db,
|
||||
demandPrincipal,
|
||||
async () => ({ name: 'Customer', side: 'demand' }),
|
||||
createAccountMutationDefinition(),
|
||||
);
|
||||
|
||||
assert.deepEqual(events, ['begin', 'account', 'agent-task', 'activity', 'commit']);
|
||||
});
|
||||
|
||||
it('rejects a demand deal attached to a supply-only account', async () => {
|
||||
const tx = {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: async () => [{ id: '10000000-0000-4000-8000-000000000001', side: 'supply' }],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
const db = {
|
||||
transaction: async (work: (transaction: unknown) => Promise<unknown>) => work(tx),
|
||||
} as unknown as Database;
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(
|
||||
db,
|
||||
demandPrincipal,
|
||||
async () => ({
|
||||
accountId: '10000000-0000-4000-8000-000000000001',
|
||||
name: 'Impossible relationship',
|
||||
productLine: 'compute_reserved',
|
||||
stage: 'qualification',
|
||||
currency: 'USD',
|
||||
msaExecuted: false,
|
||||
dpaExecuted: false,
|
||||
}),
|
||||
createDemandDealMutationDefinition(),
|
||||
),
|
||||
(error: unknown) =>
|
||||
error instanceof MutationError && error.code === 'relationship_mismatch',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { describe, it } from 'node:test';
|
||||
import type { Database } from '@pig/db';
|
||||
import type { Principal } from '../src/lib/auth';
|
||||
import { AuthError } from '../src/lib/auth';
|
||||
import { executeMutation } from '../src/lib/mutation';
|
||||
import {
|
||||
createSlackLinkMutationDefinition,
|
||||
parseSlashRequirement,
|
||||
verifySlackRequest,
|
||||
} from '../src/routes/slack';
|
||||
import { NotificationDeliveryError } from '../src/services/notifier';
|
||||
import { SlackNotifier, slackClientMessageId } from '../src/services/slack';
|
||||
|
||||
const NOW = 1_786_579_200;
|
||||
const SECRET = 'test-signing-secret';
|
||||
|
||||
function signature(timestamp: number, body: string): string {
|
||||
return `v0=${createHmac('sha256', SECRET).update(`v0:${timestamp}:${body}`).digest('hex')}`;
|
||||
}
|
||||
|
||||
describe('Slack request verification', () => {
|
||||
it('accepts the exact signed bytes and rejects tampering', () => {
|
||||
const body = 'team_id=T1&channel_id=C1&text=8+H100_80GB';
|
||||
const signed = signature(NOW, body);
|
||||
assert.equal(verifySlackRequest(SECRET, String(NOW), signed, body, NOW), true);
|
||||
assert.equal(verifySlackRequest(SECRET, String(NOW), signed, `${body}+fabric`, NOW), false);
|
||||
});
|
||||
|
||||
it('rejects replayed requests outside Slack\'s five-minute window', () => {
|
||||
const body = 'team_id=T1&channel_id=C1&text=8+H100_80GB';
|
||||
const old = NOW - 301;
|
||||
assert.equal(verifySlackRequest(SECRET, String(old), signature(old, body), body, NOW), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Slack capacity command', () => {
|
||||
it('turns transport syntax into a CapacityService requirement without matching in the handler', () => {
|
||||
assert.deepEqual(
|
||||
parseSlashRequirement('8 H100_80GB hours=640 max=2.50 fabric tier=secure_cloud'),
|
||||
{
|
||||
gpuCount: 8,
|
||||
gpuType: 'H100_80GB',
|
||||
totalGpuHours: 640,
|
||||
maxPricePerGpuHourCents: 250,
|
||||
requiresHighSpeedInterconnect: true,
|
||||
minSecurityTier: 'secure_cloud',
|
||||
},
|
||||
);
|
||||
assert.equal(parseSlashRequirement('eight H100_80GB'), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Slack channel link authorization', () => {
|
||||
it('denies non-admins before reading link input or opening a transaction', async () => {
|
||||
const events: string[] = [];
|
||||
const principal: Principal = {
|
||||
userId: '00000000-0000-4000-8000-000000000001',
|
||||
email: 'seller@example.com',
|
||||
name: 'Seller',
|
||||
isPlatformAdmin: false,
|
||||
teams: [{ team: 'demand', role: 'admin' }],
|
||||
via: 'jwt',
|
||||
scopes: ['read', 'write'],
|
||||
};
|
||||
const db = {
|
||||
transaction: async () => {
|
||||
events.push('transaction');
|
||||
},
|
||||
} as unknown as Database;
|
||||
|
||||
await assert.rejects(
|
||||
executeMutation(
|
||||
db,
|
||||
principal,
|
||||
async () => {
|
||||
events.push('body');
|
||||
return {};
|
||||
},
|
||||
createSlackLinkMutationDefinition(),
|
||||
),
|
||||
(error: unknown) => error instanceof AuthError && error.code === 'insufficient_permission',
|
||||
);
|
||||
assert.deepEqual(events, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Slack delivery decisions', () => {
|
||||
it('reuses a deterministic client message id across retries', async () => {
|
||||
const bodies: Record<string, unknown>[] = [];
|
||||
const notifier = new SlackNotifier({
|
||||
botToken: 'xoxb-test',
|
||||
fetchImpl: async (_input, init) => {
|
||||
bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
|
||||
return Response.json({ ok: true, ts: '123.456' });
|
||||
},
|
||||
});
|
||||
const envelope = {
|
||||
idempotencyKey: 'slack:link:event',
|
||||
destination: 'C123',
|
||||
notification: {
|
||||
kind: 'stage_change' as const,
|
||||
accountId: '10000000-0000-4000-8000-000000000001',
|
||||
dealId: '20000000-0000-4000-8000-000000000002',
|
||||
dealSide: 'demand' as const,
|
||||
dealName: 'Reserved H100 cluster',
|
||||
fromStage: 'proposal',
|
||||
toStage: 'procurement',
|
||||
changedAt: '2026-08-12T12:00:00.000Z',
|
||||
},
|
||||
};
|
||||
await notifier.send(envelope);
|
||||
await notifier.send(envelope);
|
||||
assert.equal(bodies[0]?.client_msg_id, slackClientMessageId(envelope.idempotencyKey));
|
||||
assert.equal(bodies[1]?.client_msg_id, bodies[0]?.client_msg_id);
|
||||
});
|
||||
|
||||
it('marks rate limits retryable and honours Slack retry-after', async () => {
|
||||
const notifier = new SlackNotifier({
|
||||
botToken: 'xoxb-test',
|
||||
fetchImpl: async () =>
|
||||
new Response('', { status: 429, headers: { 'retry-after': '7' } }),
|
||||
});
|
||||
await assert.rejects(
|
||||
notifier.send({
|
||||
idempotencyKey: 'one',
|
||||
destination: 'C1',
|
||||
notification: {
|
||||
kind: 'idle_capacity',
|
||||
accountId: '10000000-0000-4000-8000-000000000001',
|
||||
commitmentId: '20000000-0000-4000-8000-000000000002',
|
||||
commitmentName: 'Eight H100s',
|
||||
gpuType: 'H100_80GB',
|
||||
idleGpuHours: 640,
|
||||
idleCostCents: 120_000,
|
||||
utilisation: 0,
|
||||
observedAt: '2026-08-12T12:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
(error: unknown) =>
|
||||
error instanceof NotificationDeliveryError &&
|
||||
error.retryable &&
|
||||
error.retryAfterMs === 7_000,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": { "noEmit": true, "types": ["node"] },
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
"include": ["src/**/*.ts", "test/**/*.ts", "e2e/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user