Rebuild Piggy's interface, and give the demo book a business to describe
Piggy answered in raw markdown, threw away every tool result it streamed, and fought the reader's scroll on every token. The three surfaces that made it worth having — what it read, how it reasoned, what it cost — were all on the wire and none of them reached the screen. The transcript is now composed of five parts under components/piggy: answers render through streamdown, the container sticks to the bottom without pinning the reader there, tool steps say what they read and link to the record, and each turn carries its model and token count. Three lifecycle bugs went with them: Stop left a permanent spinner, a truncated stream was indistinguishable from thinking, and a failed send destroyed the message it failed to send. Underneath, the inference path grew timeouts, jittered retries on 429 and 5xx, tolerance of the malformed frames a 30B model emits, and an agent_runs row per turn so chat spend is observable. The system prompt now states that a field ending in Cents is cents — without it nemotron renders costPerGpuHourCents: 189 as "$189 per GPU-hour", which is a 100x error on the most scrutinised number in the room. The demo book was arithmetically incoherent: every deal's value contradicted its own allocation revenue by up to 3.6x, nothing had ever closed, no customer had any paper, and the marketplace was empty. Deal value is now derived from the allocation, the book clears 5.3% across five blocks with one deliberately underwater, and the renewal, compliance and agent-provenance machinery finally has rows to act on. A --clear that deleted every obligation, SLA term and capacity request in the database regardless of origin is scoped to the demo's own ids. Around that: accounts have a detail page, ⌘K searches the book, Settings can mint the API keys it always claimed to, and deploy.sh actually ships the agent instead of silently skipping its compose profile. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+196
-19
@@ -9,7 +9,7 @@
|
||||
import { Hono } from 'hono';
|
||||
import { cors } from 'hono/cors';
|
||||
import { logger } from 'hono/logger';
|
||||
import { and, desc, eq, ilike, isNull, or, sql } from 'drizzle-orm';
|
||||
import { and, desc, eq, ilike, inArray, isNull, or, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import type { Database } from '@pig/db';
|
||||
import {
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
capacityCommitments,
|
||||
contacts,
|
||||
contracts,
|
||||
dealContacts,
|
||||
demandDeals,
|
||||
supplyDeals,
|
||||
teamMemberships,
|
||||
@@ -26,13 +27,16 @@ import {
|
||||
} from '@pig/db';
|
||||
import {
|
||||
ACCENTS,
|
||||
DEMAND_OPEN_STAGES,
|
||||
DEMAND_STAGES,
|
||||
SECURITY_TIERS,
|
||||
SUPPLY_OPEN_STAGES,
|
||||
SUPPLY_STAGES,
|
||||
TEAMS,
|
||||
THEME_MODES,
|
||||
isValidAccent,
|
||||
isValidThemeMode,
|
||||
type CalendarEvent,
|
||||
} from '@pig/core';
|
||||
import type { Config } from './lib/config';
|
||||
import {
|
||||
@@ -48,6 +52,7 @@ import {
|
||||
import { apiError } from './lib/mutation';
|
||||
import { createMediaRoutes } from './lib/media';
|
||||
import { CapacityService } from './services/capacity';
|
||||
import { CalendarService } from './services/calendar';
|
||||
import { createSignupRoute } from './routes/signup';
|
||||
import { createRegisterRoute } from './routes/register';
|
||||
import { createDemandStageMutation } from './routes/deals';
|
||||
@@ -82,6 +87,9 @@ export function createApp(
|
||||
const app = new Hono<Env>();
|
||||
const auth = createAuthenticator(config, db, authProvider);
|
||||
const capacity = new CapacityService(db);
|
||||
// The dashboard's compliance tile reads the same projection the Calendar's
|
||||
// lanes do, rather than a second copy of the expiry queries.
|
||||
const calendar = new CalendarService(db);
|
||||
const notifications = new NotificationOutbox(db);
|
||||
|
||||
if (!config.isProduction) app.use('*', logger());
|
||||
@@ -345,18 +353,36 @@ export function createApp(
|
||||
const [account] = await db.select().from(accounts).where(eq(accounts.id, id)).limit(1);
|
||||
if (!account) return c.json({ error: 'Not found' }, 404);
|
||||
|
||||
const [accountContacts, demand, supply, paperwork, recentActivity] = await Promise.all([
|
||||
db.select().from(contacts).where(eq(contacts.accountId, id)),
|
||||
db.select().from(demandDeals).where(eq(demandDeals.accountId, id)),
|
||||
db.select().from(supplyDeals).where(eq(supplyDeals.accountId, id)),
|
||||
db.select().from(contracts).where(eq(contracts.accountId, id)),
|
||||
db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.accountId, id))
|
||||
.orderBy(desc(activities.occurredAt))
|
||||
.limit(50),
|
||||
]);
|
||||
const [accountContacts, demand, supply, paperwork, recentActivity, buyingGroup] =
|
||||
await Promise.all([
|
||||
db.select().from(contacts).where(eq(contacts.accountId, id)),
|
||||
db.select().from(demandDeals).where(eq(demandDeals.accountId, id)),
|
||||
db.select().from(supplyDeals).where(eq(supplyDeals.accountId, id)),
|
||||
db.select().from(contracts).where(eq(contracts.accountId, id)),
|
||||
db
|
||||
.select()
|
||||
.from(activities)
|
||||
.where(eq(activities.accountId, id))
|
||||
.orderBy(desc(activities.occurredAt))
|
||||
.limit(50),
|
||||
/*
|
||||
* The buying group, joined through the deals rather than filtered on
|
||||
* the account: `deal_contacts` carries no account id, so without the
|
||||
* join every role in the workspace would come back. Only the three
|
||||
* columns the panel reads are selected — the row's own id and
|
||||
* timestamp say nothing a reader needs, and a contact's role on a deal
|
||||
* is the one fact this endpoint could not otherwise state.
|
||||
*/
|
||||
db
|
||||
.select({
|
||||
demandDealId: dealContacts.demandDealId,
|
||||
contactId: dealContacts.contactId,
|
||||
role: dealContacts.role,
|
||||
})
|
||||
.from(dealContacts)
|
||||
.innerJoin(demandDeals, eq(demandDeals.id, dealContacts.demandDealId))
|
||||
.where(eq(demandDeals.accountId, id)),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
account,
|
||||
@@ -365,6 +391,7 @@ export function createApp(
|
||||
supplyDeals: supply,
|
||||
contracts: paperwork,
|
||||
activities: recentActivity,
|
||||
dealContacts: buyingGroup,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -488,25 +515,42 @@ export function createApp(
|
||||
*/
|
||||
app.get('/api/dashboard', async (c) => {
|
||||
const p = c.get('principal');
|
||||
const [margin, idle, openDemand, openSupply, recent] = await Promise.all([
|
||||
const [margin, idle, openDemand, openSupply, recent, compliance] = await Promise.all([
|
||||
capacity.marginReport(),
|
||||
// 0.15 rather than 0.2: a block sitting exactly on the threshold would
|
||||
// otherwise flip in and out of the alert list on floating-point noise,
|
||||
// and 15% idle is worth a seller's attention anyway.
|
||||
capacity.idleCapacity({ thresholdPct: 0.15 }),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.select({
|
||||
count: sql<number>`count(*)::int`,
|
||||
// Postgres widens `sum(integer)` to bigint, which arrives as text.
|
||||
// Coerced once here so the wire carries a number, per the money rule.
|
||||
acvCents: sql<string>`coalesce(sum(${demandDeals.acvCents}), 0)`,
|
||||
})
|
||||
.from(demandDeals)
|
||||
.where(sql`${demandDeals.stage} NOT IN ('closed_won','closed_lost')`),
|
||||
.where(inArray(demandDeals.stage, [...DEMAND_OPEN_STAGES])),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(supplyDeals)
|
||||
.where(sql`${supplyDeals.stage} NOT IN ('live','churned','rejected')`),
|
||||
/*
|
||||
* The ontology decides what "open" means, not a stage list written out
|
||||
* again here. Spelled as "not churned and not rejected" this counted
|
||||
* the four `live` suppliers as open pipeline, so the tile headed "Open
|
||||
* pipeline" said six while Piggy's pipeline tool, the workspace summary
|
||||
* and the account detail page — all of which read `SUPPLY_OPEN_STAGES`
|
||||
* — said two. `live` is the supply side's won state, the counterpart of
|
||||
* `closed_won`; a signed supplier is capacity on the book, not an
|
||||
* opportunity still being worked.
|
||||
*/
|
||||
.where(inArray(supplyDeals.stage, [...SUPPLY_OPEN_STAGES])),
|
||||
db
|
||||
.select()
|
||||
.select({ activity: activities, accountName: accounts.name })
|
||||
.from(activities)
|
||||
.leftJoin(accounts, eq(accounts.id, activities.accountId))
|
||||
.orderBy(desc(activities.occurredAt))
|
||||
.limit(12),
|
||||
complianceOutlook(calendar, new Date()),
|
||||
]);
|
||||
|
||||
return c.json({
|
||||
@@ -515,8 +559,13 @@ export function createApp(
|
||||
blocks: margin.blocks.length,
|
||||
idleAlerts: idle.slice(0, 5),
|
||||
openDemandDeals: openDemand[0]?.count ?? 0,
|
||||
openDemandAcvCents: Number(openDemand[0]?.acvCents ?? 0),
|
||||
openSupplyDeals: openSupply[0]?.count ?? 0,
|
||||
recentActivity: recent,
|
||||
compliance,
|
||||
// The subject alone reads as an anonymous feed — "Chased the firm quote"
|
||||
// says nothing until you know whose. The name comes from the join rather
|
||||
// than a second request per row.
|
||||
recentActivity: recent.map(({ activity, accountName }) => ({ ...activity, accountName })),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -533,3 +582,131 @@ export function createApp(
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- compliance
|
||||
|
||||
/**
|
||||
* The window the landing view asks about, and why it is asymmetric.
|
||||
*
|
||||
* Forward, a quarter: the shortest horizon in which a licence renewal can
|
||||
* realistically be started and finished, so anything nearer is already late.
|
||||
*
|
||||
* Backward, a year — and that half is the reason this exists. The Calendar's
|
||||
* compliance lane can only report the quarter being read, and says so on the
|
||||
* card: an authorisation that lapsed in an earlier quarter is outside that
|
||||
* window, not cleared by it. The Overview is the screen everyone opens, so it
|
||||
* is the one that has to keep saying it. The bound is only there to stop the
|
||||
* scan growing without limit; a lapse itself never expires.
|
||||
*/
|
||||
const COMPLIANCE_HORIZON_DAYS = 90;
|
||||
const COMPLIANCE_LOOKBACK_DAYS = 365;
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/** How many rows travel. The counts beside them stay exact whatever this is. */
|
||||
const COMPLIANCE_ITEM_LIMIT = 6;
|
||||
|
||||
/**
|
||||
* Both columns are free text by design — new authorisation types and new
|
||||
* attestation regimes appear faster than an enum is updated — so an unrecognised
|
||||
* value is made readable rather than dropped or shown raw.
|
||||
*/
|
||||
const AUTHORIZATION_TYPE_LABELS: Readonly<Record<string, string>> = {
|
||||
none: 'No authorisation on file',
|
||||
licence: 'Export licence',
|
||||
listed_entity: 'Listed-entity authorisation',
|
||||
dc_veu: 'Validated end user',
|
||||
case_by_case: 'Case-by-case licence',
|
||||
};
|
||||
|
||||
const COMPLIANCE_CLAIM_LABELS: Readonly<Record<string, string>> = {
|
||||
soc2: 'SOC 2',
|
||||
iso27001: 'ISO 27001',
|
||||
iso42001: 'ISO 42001',
|
||||
pentest: 'Penetration test',
|
||||
cyber_insurance: 'Cyber insurance',
|
||||
};
|
||||
|
||||
export interface ComplianceItem {
|
||||
id: string;
|
||||
kind: 'authorization' | 'artifact';
|
||||
label: string;
|
||||
reference: string | null;
|
||||
accountId: string | null;
|
||||
accountName: string | null;
|
||||
expiresAt: string;
|
||||
/** Decided by the projection's clock, so one request cannot disagree with itself. */
|
||||
lapsed: boolean;
|
||||
/** Rules in flux for this counterparty: the date on file is not enough. */
|
||||
volatile: boolean;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface ComplianceOutlook {
|
||||
horizonDays: number;
|
||||
lapsedCount: number;
|
||||
expiringCount: number;
|
||||
items: ComplianceItem[];
|
||||
}
|
||||
|
||||
async function complianceOutlook(
|
||||
calendar: CalendarService,
|
||||
now: Date,
|
||||
): Promise<ComplianceOutlook> {
|
||||
const projection = await calendar.project({
|
||||
from: new Date(now.getTime() - COMPLIANCE_LOOKBACK_DAYS * DAY_MS),
|
||||
to: new Date(now.getTime() + COMPLIANCE_HORIZON_DAYS * DAY_MS),
|
||||
kinds: ['authorization_expiry', 'artifact_expiry'],
|
||||
});
|
||||
|
||||
const items = projection.events.map(toComplianceItem).sort(byUrgency);
|
||||
return {
|
||||
horizonDays: COMPLIANCE_HORIZON_DAYS,
|
||||
lapsedCount: items.filter((item) => item.lapsed).length,
|
||||
expiringCount: items.filter((item) => !item.lapsed).length,
|
||||
items: items.slice(0, COMPLIANCE_ITEM_LIMIT),
|
||||
};
|
||||
}
|
||||
|
||||
function toComplianceItem(event: CalendarEvent): ComplianceItem {
|
||||
const isAuthorization = event.kind === 'authorization_expiry';
|
||||
const type = metaString(event.meta, isAuthorization ? 'authorizationType' : 'claim');
|
||||
const labels = isAuthorization ? AUTHORIZATION_TYPE_LABELS : COMPLIANCE_CLAIM_LABELS;
|
||||
return {
|
||||
id: event.id,
|
||||
kind: isAuthorization ? 'authorization' : 'artifact',
|
||||
label: type
|
||||
? (labels[type] ?? humanised(type))
|
||||
: isAuthorization
|
||||
? 'Export authorisation'
|
||||
: 'Compliance artefact',
|
||||
reference: metaString(event.meta, 'reference'),
|
||||
accountId: event.accountId,
|
||||
accountName: event.accountName,
|
||||
expiresAt: event.startsAt,
|
||||
lapsed: event.state === 'overdue',
|
||||
volatile: event.meta.volatile === true,
|
||||
href: event.href,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lapsed before expiring, and inside each group the one to act on first: the
|
||||
* most recent lapse — the one still recoverable — then the nearest deadline.
|
||||
*/
|
||||
function byUrgency(a: ComplianceItem, b: ComplianceItem): number {
|
||||
if (a.lapsed !== b.lapsed) return a.lapsed ? -1 : 1;
|
||||
const left = Date.parse(a.expiresAt);
|
||||
const right = Date.parse(b.expiresAt);
|
||||
return a.lapsed ? right - left : left - right;
|
||||
}
|
||||
|
||||
/** `meta` is deliberately untyped on a projected event; nothing widens to `any` here. */
|
||||
function metaString(meta: Record<string, unknown>, key: string): string | null {
|
||||
const value = meta[key];
|
||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function humanised(value: string): string {
|
||||
const spaced = value.replace(/_/g, ' ');
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
||||
}
|
||||
|
||||
@@ -120,7 +120,12 @@ export function createAuthenticator(
|
||||
// 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);
|
||||
// Ordered by creation rather than left to the heap. An unordered
|
||||
// limit(1) lets Postgres return any row, and the order shifts after an
|
||||
// update, so who you are with auth disabled changed between runs — and
|
||||
// with it every capability gate on the page. The first seeded user is
|
||||
// the stable answer.
|
||||
const [devUser] = await db.select().from(users).orderBy(users.createdAt).limit(1);
|
||||
if (!devUser) {
|
||||
throw new AuthError(
|
||||
'Auth is disabled and the database has no users. Run `npm run db:seed`.',
|
||||
|
||||
@@ -49,8 +49,9 @@
|
||||
import { Hono } from 'hono';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { realpath, stat } from 'node:fs/promises';
|
||||
import { join, resolve, sep } from 'node:path';
|
||||
import { dirname, join, resolve, sep } from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { isLearnMediaFilename, learnMediaContentType, LEARN_MEDIA_PATH_PREFIX } from '@pig/core';
|
||||
|
||||
/**
|
||||
@@ -61,16 +62,27 @@ import { isLearnMediaFilename, learnMediaContentType, LEARN_MEDIA_PATH_PREFIX }
|
||||
* a reason to refuse to boot — an install with no videos should serve 404s and
|
||||
* work in every other respect.
|
||||
*
|
||||
* The default is relative to the working directory, which is the repository
|
||||
* root in development. The container sets it explicitly to `/app/media`, which
|
||||
* is where docker-compose bind-mounts the host directory read-only.
|
||||
* A relative path — including the default — is resolved against the REPOSITORY
|
||||
* ROOT, not the working directory. It used to be the working directory, and
|
||||
* that was wrong in the one case it had to be right: `pnpm -F @pig/api dev`
|
||||
* runs with the cwd set to `apps/api`, so the documented `PIG_MEDIA_DIR=./media`
|
||||
* resolved to `apps/api/media`, which does not exist, and every Learn video
|
||||
* 404'd while the poster fell back to a placeholder that looks deliberate. The
|
||||
* container copies the tree to `/app`, so the root is `/app` there and the
|
||||
* default lands on `/app/media` — exactly where docker-compose bind-mounts the
|
||||
* host directory read-only, and what it sets `PIG_MEDIA_DIR` to anyway.
|
||||
*/
|
||||
export const LEARN_MEDIA_DIR_ENV = 'PIG_MEDIA_DIR';
|
||||
const DEFAULT_MEDIA_DIR = './media';
|
||||
|
||||
// apps/api/src/lib/media.ts — four levels up is the repository root.
|
||||
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..', '..');
|
||||
|
||||
export function learnMediaRoot(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const configured = env[LEARN_MEDIA_DIR_ENV]?.trim();
|
||||
return resolve(configured && configured.length > 0 ? configured : DEFAULT_MEDIA_DIR);
|
||||
// `resolve` ignores the base when the second argument is already absolute,
|
||||
// so an absolute PIG_MEDIA_DIR is honoured untouched.
|
||||
return resolve(REPO_ROOT, configured && configured.length > 0 ? configured : DEFAULT_MEDIA_DIR);
|
||||
}
|
||||
|
||||
/** The mount path, exported so `app.ts` and the resolver cannot disagree. */
|
||||
@@ -147,9 +159,20 @@ export function createMediaRoutes(options: { root?: string } = {}) {
|
||||
* directory is operator-populated and mounted read-only, so this was
|
||||
* hardening rather than a live hole — but it becomes real the moment the
|
||||
* directory is filled by an rsync or a tarball unpack.
|
||||
*
|
||||
* BOTH sides are resolved, though. Comparing a real file path against a
|
||||
* LEXICAL root rejects the entire directory the moment the media root is
|
||||
* itself reached through a symlink — a symlinked checkout, or a data
|
||||
* volume under /var that is a link into /mnt — and the symptom is a
|
||||
* blanket 404 on every video with nothing in the log to say why.
|
||||
* Resolving the root the same way the file is resolved keeps the defence
|
||||
* exactly as strict: the file still has to sit inside the real
|
||||
* directory, so a link planted among the videos and pointing at
|
||||
* /etc/passwd is still refused.
|
||||
*/
|
||||
const realRoot = await realpath(root);
|
||||
const real = await realpath(path);
|
||||
if (real !== path && !real.startsWith(root + sep)) return c.notFound();
|
||||
if (!real.startsWith(realRoot + sep)) return c.notFound();
|
||||
const info = await stat(real);
|
||||
if (!info.isFile()) return c.notFound();
|
||||
size = info.size;
|
||||
|
||||
@@ -27,6 +27,19 @@ export function normaliseInferenceEndpoint(value: string): string {
|
||||
return value.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Is this endpoint something other than the Prime *compute* API host?
|
||||
*
|
||||
* A blocklist of exactly one hostname, which is only sound because it is no
|
||||
* longer a gate on anything: it used to admit an arbitrary operator-supplied
|
||||
* URL into `platform_settings`, and "anything but this one host" is not a safe
|
||||
* rule for a URL the server will later call. The writable field is gone (see
|
||||
* `platformSettingsSchema`), so this now only reports on `PIGGY_INFERENCE_BASE`
|
||||
* — a value that arrives from the deployment environment, where an operator who
|
||||
* can set it can already do anything the process can. Kept because pointing
|
||||
* inference at the compute host is a real and easy mistake, and the two hosts
|
||||
* are genuinely different services.
|
||||
*/
|
||||
export function isInferenceEndpoint(value: string): boolean {
|
||||
try {
|
||||
return new URL(value).hostname !== 'api.primeintellect.ai';
|
||||
@@ -35,15 +48,18 @@ export function isInferenceEndpoint(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `piggyModel` and `piggyInferenceBase` are deliberately absent.
|
||||
*
|
||||
* The columns still exist, but nothing reads them: `apps/piggy` loads its model
|
||||
* and inference base from `process.env` at boot and never consults
|
||||
* `platform_settings`. Accepting writes here gave an admin a field that saved,
|
||||
* reported success, and changed nothing about the running agent. The truth is
|
||||
* reported instead — see `piggyRuntimeStatus` — and `.strict()` now rejects
|
||||
* either key rather than pretending to store it.
|
||||
*/
|
||||
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(),
|
||||
@@ -88,6 +104,9 @@ export const memberAccessSchema = z
|
||||
function initialSettings(config: Config) {
|
||||
return {
|
||||
id: SETTINGS_ID,
|
||||
// Seeded from the environment so a fresh row is not misleading, then never
|
||||
// updated again: these two columns are vestigial, and dropping them is a
|
||||
// migration rather than a route change.
|
||||
piggyModel: config.PIGGY_MODEL,
|
||||
piggyInferenceBase: normaliseInferenceEndpoint(config.PIGGY_INFERENCE_BASE),
|
||||
piggyEnabled: config.PIGGY_ENABLED,
|
||||
@@ -96,6 +115,88 @@ function initialSettings(config: Config) {
|
||||
};
|
||||
}
|
||||
|
||||
/** Loopback or a Compose neighbour: a probe unanswered in a second is dead. */
|
||||
const PIGGY_HEALTH_TIMEOUT_MS = 1_500;
|
||||
|
||||
export interface PiggyChatServerHealth {
|
||||
ok: boolean;
|
||||
/**
|
||||
* The model the chat server says it is calling. Null when it did not answer,
|
||||
* and null rather than the environment's value on purpose: the API container
|
||||
* and the Piggy container hold separate copies of `PIGGY_MODEL`, so only the
|
||||
* process doing the inference can say what is actually in force.
|
||||
*/
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the Piggy chat server whether it is alive.
|
||||
*
|
||||
* `/internal/health` is unauthenticated at the other end by design, so no token
|
||||
* travels here — which is what makes this answerable for the deployment most
|
||||
* worth diagnosing, one whose `PIGGY_INTERNAL_TOKEN` is wrong. It is also the
|
||||
* only signal the API has about a missing `PIGGY_INFERENCE_API_KEY`: that key
|
||||
* never reaches this container, and Piggy exits at boot without it, so a
|
||||
* crash-looping agent shows up here as a refused connection.
|
||||
*/
|
||||
export async function probePiggyChatServer(
|
||||
baseUrl: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<PiggyChatServerHealth> {
|
||||
try {
|
||||
const response = await fetchImpl(`${baseUrl.replace(/\/+$/, '')}/internal/health`, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(PIGGY_HEALTH_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
// Cancelled rather than left open: an undrained body holds the socket.
|
||||
await response.body?.cancel().catch(() => {});
|
||||
return { ok: false, model: null };
|
||||
}
|
||||
return { ok: true, model: reportedModel(await response.json().catch(() => null)) };
|
||||
} catch {
|
||||
return { ok: false, model: null };
|
||||
}
|
||||
}
|
||||
|
||||
function reportedModel(payload: unknown): string | null {
|
||||
if (typeof payload !== 'object' || payload === null) return null;
|
||||
const { model } = payload as { model?: unknown };
|
||||
return typeof model === 'string' && model.length > 0 ? model : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What is true about Piggy right now, as opposed to what the database was told.
|
||||
*
|
||||
* Every field here is derived from the environment or from a live probe. The
|
||||
* panel this feeds exists because an operator whose Piggy is silently down had
|
||||
* nothing to look at: the settings page showed a model, an endpoint and a green
|
||||
* toggle, all of which were stored values that no running process reads.
|
||||
*/
|
||||
export function piggyRuntimeStatus(
|
||||
row: PlatformSettings,
|
||||
config: Config,
|
||||
health: PiggyChatServerHealth | null,
|
||||
) {
|
||||
const inferenceBase = normaliseInferenceEndpoint(config.PIGGY_INFERENCE_BASE ?? '');
|
||||
return {
|
||||
/** `PIGGY_ENABLED`. The outer gate; nothing in the UI can open it. */
|
||||
enabledByEnvironment: Boolean(config.PIGGY_ENABLED),
|
||||
/** The stored toggle. Gates interactive chat only — never the worker. */
|
||||
chatEnabled: row.piggyEnabled,
|
||||
internalUrlConfigured: Boolean(config.PIGGY_INTERNAL_URL),
|
||||
/** Never the token itself: a boolean is the whole of what an admin needs. */
|
||||
internalTokenConfigured: Boolean(config.PIGGY_INTERNAL_TOKEN),
|
||||
model: config.PIGGY_MODEL ?? null,
|
||||
inferenceBase: inferenceBase.length > 0 ? inferenceBase : null,
|
||||
/** False means inference is pointed at the compute API, which cannot work. */
|
||||
inferenceIsolated: inferenceBase.length > 0 ? isInferenceEndpoint(inferenceBase) : true,
|
||||
/** True, false, or null for "not probed in this response". */
|
||||
reachable: health === null ? null : health.ok,
|
||||
reportedModel: health?.model ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensurePlatformSettings(config: Config, db: Database): Promise<PlatformSettings> {
|
||||
await db.insert(platformSettings).values(initialSettings(config)).onConflictDoNothing();
|
||||
const [row] = await db
|
||||
@@ -107,13 +208,16 @@ export async function ensurePlatformSettings(config: Config, db: Database): Prom
|
||||
return row;
|
||||
}
|
||||
|
||||
export function platformSettingsResponse(row: PlatformSettings, config: Config) {
|
||||
export function platformSettingsResponse(
|
||||
row: PlatformSettings,
|
||||
config: Config,
|
||||
piggyHealth: PiggyChatServerHealth | null = null,
|
||||
) {
|
||||
const storedCredential = Boolean(row.primeApiKeyEncrypted);
|
||||
const environmentCredential = Boolean(config.PRIME_API_KEY);
|
||||
return {
|
||||
piggyModel: row.piggyModel,
|
||||
piggyInferenceBase: row.piggyInferenceBase,
|
||||
piggyEnabled: row.piggyEnabled,
|
||||
piggy: piggyRuntimeStatus(row, config, piggyHealth),
|
||||
primeComputeBase: config.PRIME_API_BASE,
|
||||
primeApiKey: {
|
||||
configured: storedCredential || environmentCredential,
|
||||
@@ -172,12 +276,38 @@ export function createAdminSettingsRoutes(
|
||||
config: Config,
|
||||
db: Database,
|
||||
onSettingsChanged?: () => Promise<void>,
|
||||
options: { fetchImpl?: typeof fetch } = {},
|
||||
) {
|
||||
const app = new Hono<ApiEnv>();
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
|
||||
/**
|
||||
* One probe in flight at a time, and deliberately not cached beyond that.
|
||||
*
|
||||
* The settings page refetches on focus and an operator diagnosing a dead
|
||||
* Piggy will press Recheck the moment the container restarts; a Recheck that
|
||||
* answers from a cache would be the same class of lie this panel exists to
|
||||
* remove. The single-flight guard is enough, because this route is
|
||||
* `settings:admin` and rarely called — unlike `/api/piggy/status`, which is
|
||||
* hit by a dock on every page and so caches its own copy of the probe.
|
||||
*/
|
||||
let inFlightHealth: Promise<PiggyChatServerHealth> | null = null;
|
||||
async function piggyHealth(): Promise<PiggyChatServerHealth | null> {
|
||||
const url = config.PIGGY_INTERNAL_URL;
|
||||
if (!url) return null;
|
||||
inFlightHealth ??= probePiggyChatServer(url, fetchImpl).finally(() => {
|
||||
inFlightHealth = null;
|
||||
});
|
||||
return inFlightHealth;
|
||||
}
|
||||
|
||||
app.get('/api/admin/settings', async (c) => {
|
||||
requireCapability(c.get('principal'), 'settings:admin');
|
||||
return c.json(platformSettingsResponse(await ensurePlatformSettings(config, db), config));
|
||||
const [row, health] = await Promise.all([
|
||||
ensurePlatformSettings(config, db),
|
||||
piggyHealth(),
|
||||
]);
|
||||
return c.json(platformSettingsResponse(row, config, health));
|
||||
});
|
||||
|
||||
const updateSettings = mutation(db, {
|
||||
@@ -189,8 +319,6 @@ export function createAdminSettingsRoutes(
|
||||
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) {
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { PIGGY_PAGE_ROUTES, PIGGY_RECORD_TYPES } from '@pig/core';
|
||||
import {
|
||||
PIGGY_PAGE_ROUTES,
|
||||
PIGGY_RECORD_TYPES,
|
||||
permissionGranted,
|
||||
resolveReadPermissionGrants,
|
||||
} from '@pig/core';
|
||||
import type { ReadCapability } from '@pig/core';
|
||||
import type { Database } from '@pig/db';
|
||||
import { Hono } from 'hono';
|
||||
import { stream } from 'hono/streaming';
|
||||
import { z } from 'zod';
|
||||
import type { Config } from '../lib/config';
|
||||
import type { ApiEnv } from '../lib/mutation';
|
||||
import { ensurePlatformSettings } from './admin-settings';
|
||||
import type { Principal } from '../lib/auth';
|
||||
import { apiError, type ApiEnv } from '../lib/mutation';
|
||||
import { ensurePlatformSettings, probePiggyChatServer } from './admin-settings';
|
||||
import { createAttemptLimiter, type AttemptLimiter } from './learn';
|
||||
import { piggyContextCapability } from './read-guards';
|
||||
|
||||
/**
|
||||
* Derived from the @pig/core tuples, and kept in step with the identical
|
||||
@@ -45,6 +54,22 @@ const requestSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* The whole product runs on a fixed Prime Intellect credit, so the quota that
|
||||
* matters is per person and per hour, not per second. Thirty is roughly a
|
||||
* working session's worth of questions: nobody who is using Piggy notices it,
|
||||
* and a runaway client burns an hour's allowance rather than the balance.
|
||||
*/
|
||||
export const PIGGY_MESSAGES_PER_HOUR = 30;
|
||||
const PIGGY_RATE_WINDOW_MS = 60 * 60 * 1_000;
|
||||
|
||||
/**
|
||||
* How long a health probe is believed. Short enough that restarting the Piggy
|
||||
* service un-greys the dock within a page refresh or two, long enough that a
|
||||
* dock on every page does not turn `/api/piggy/status` into a loopback flood.
|
||||
*/
|
||||
const PIGGY_HEALTH_CACHE_MS = 10_000;
|
||||
|
||||
export interface PiggyChatProxyOptions {
|
||||
enabled: boolean;
|
||||
internalUrl?: string;
|
||||
@@ -56,6 +81,11 @@ export interface PiggyChatProxyOptions {
|
||||
* did nothing.
|
||||
*/
|
||||
resolvePiggyEnabled?: () => Promise<boolean>;
|
||||
/** Messages per user per hour. Defaults to `PIGGY_MESSAGES_PER_HOUR`. */
|
||||
messagesPerHour?: number;
|
||||
/** Injected by the tests so a quota can be exhausted without waiting. */
|
||||
limiter?: AttemptLimiter;
|
||||
healthCacheMs?: number;
|
||||
}
|
||||
|
||||
/** The stored toggle. Paired with `createPiggyChatRoutes` at composition. */
|
||||
@@ -68,61 +98,159 @@ export function createPiggyChatRoutes(options: PiggyChatProxyOptions) {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
// Configuration cannot change under a running process; the toggle can.
|
||||
const configured = Boolean(options.enabled && options.internalUrl && options.internalToken);
|
||||
const base = options.internalUrl?.replace(/\/$/, '') ?? '';
|
||||
const healthCacheMs = options.healthCacheMs ?? PIGGY_HEALTH_CACHE_MS;
|
||||
const limiter =
|
||||
options.limiter ??
|
||||
createAttemptLimiter({
|
||||
limit: options.messagesPerHour ?? PIGGY_MESSAGES_PER_HOUR,
|
||||
windowMs: PIGGY_RATE_WINDOW_MS,
|
||||
});
|
||||
|
||||
// ------------------------------------------------------------------ health
|
||||
|
||||
let healthy = false;
|
||||
let checkedAt = 0;
|
||||
/** One probe at a time: a dock on every page opens a burst of status calls. */
|
||||
let inFlight: Promise<boolean> | null = null;
|
||||
|
||||
/**
|
||||
* The environment variable is the outer gate and the stored setting the
|
||||
* inner one: an operator who has not provisioned Piggy cannot have it
|
||||
* switched on from the admin UI. A failed settings read falls back to the
|
||||
* outer gate rather than 503-ing every dock on the site over one bad query.
|
||||
* The same probe the settings panel runs, so a dead Piggy cannot be reported
|
||||
* dead on one screen and alive on the other. Only the caching differs, and it
|
||||
* differs on purpose — see `chatServerHealthy` below.
|
||||
*/
|
||||
async function probe(): Promise<boolean> {
|
||||
return (await probePiggyChatServer(base, fetchImpl)).ok;
|
||||
}
|
||||
|
||||
function remember(result: boolean): boolean {
|
||||
healthy = result;
|
||||
checkedAt = Date.now();
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the chat server actually answering?
|
||||
*
|
||||
* The reason this exists: `configured` tests environment variables, which
|
||||
* are equally true when the Piggy process is dead or has no inference key.
|
||||
* `/api/piggy/status` therefore reported `canUse: true` and the dock drew a
|
||||
* live composer over a service that could not answer, and the first message
|
||||
* came back as a red "Internal error" bubble. A probe makes the status
|
||||
* honest, so the dock shows its own "Piggy is unavailable" state instead.
|
||||
*/
|
||||
async function chatServerHealthy(): Promise<boolean> {
|
||||
if (Date.now() - checkedAt < healthCacheMs) return healthy;
|
||||
inFlight ??= probe()
|
||||
.then(remember)
|
||||
.finally(() => {
|
||||
inFlight = null;
|
||||
});
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* The environment variable is the outer gate, the stored setting the inner
|
||||
* one, and the probe the last word: an operator who has not provisioned
|
||||
* Piggy cannot have it switched on from the admin UI, and an operator who
|
||||
* has cannot be told it works when the process is down. A failed settings
|
||||
* read falls through to the probe rather than 503-ing every dock on the site
|
||||
* over one bad query.
|
||||
*/
|
||||
async function isAvailable(): Promise<boolean> {
|
||||
if (!configured) return false;
|
||||
if (!options.resolvePiggyEnabled) return true;
|
||||
try {
|
||||
return await options.resolvePiggyEnabled();
|
||||
} catch {
|
||||
return true;
|
||||
if (options.resolvePiggyEnabled) {
|
||||
try {
|
||||
if (!(await options.resolvePiggyEnabled())) return false;
|
||||
} catch {
|
||||
// Deliberately not a denial — see above.
|
||||
}
|
||||
}
|
||||
return chatServerHealthy();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ routes
|
||||
|
||||
routes.get('/api/piggy/status', async (c) => {
|
||||
const principal = c.get('principal');
|
||||
const available = await isAvailable();
|
||||
return c.json({
|
||||
enabled: available,
|
||||
canUse: available && principal.scopes.includes('read'),
|
||||
/**
|
||||
* The floor, not the whole authorisation: the capability a turn needs
|
||||
* depends on the context it carries, which is not knowable here. Saying
|
||||
* `true` to someone who holds no read capability at all would still be a
|
||||
* composer that can only 403, so the floor is worth checking.
|
||||
*/
|
||||
canUse: available && holdsReadCapability(principal, 'book: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,
|
||||
);
|
||||
return c.json(apiError('insufficient_scope', "This credential lacks the 'read' scope."), 403);
|
||||
}
|
||||
if (!(await isAvailable()) || !options.internalUrl || !options.internalToken) {
|
||||
return c.json({ error: 'Piggy chat is not available.', code: 'piggy_unavailable' }, 503);
|
||||
return c.json(apiError('piggy_unavailable', 'Piggy chat is not available.'), 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);
|
||||
return c.json(apiError('invalid_json', 'Request body must be valid 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 },
|
||||
apiError('invalid_request', 'Invalid Piggy chat request.', parsed.error.issues),
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const upstream = await fetchImpl(
|
||||
`${options.internalUrl.replace(/\/$/, '')}/internal/chat`,
|
||||
{
|
||||
/**
|
||||
* Authorised here and nowhere else. The chat server takes a bare
|
||||
* `principalUserId` and builds its tools from the context alone, so it has
|
||||
* no way to ask this question — the capability lives on `Principal.teams`,
|
||||
* which never crosses the hop. The relay is the last place that knows.
|
||||
*/
|
||||
const capability = piggyContextCapability(parsed.data.context);
|
||||
if (!holdsReadCapability(principal, capability)) {
|
||||
return c.json(
|
||||
apiError(
|
||||
'insufficient_permission',
|
||||
`This principal lacks the '${capability}' capability.`,
|
||||
),
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Counted after authorisation, so a caller who is being refused does not
|
||||
* spend the quota they were never going to use, and immediately before the
|
||||
* hop, so nothing that reaches inference is uncounted. Keyed on the user
|
||||
* rather than the address: the credit is spent per person, and everyone
|
||||
* behind the office NAT shares an address.
|
||||
*/
|
||||
const decision = limiter.check(principal.userId);
|
||||
if (!decision.allowed) {
|
||||
c.header('retry-after', String(decision.retryAfterSeconds));
|
||||
return c.json(
|
||||
{
|
||||
...apiError(
|
||||
'piggy_rate_limited',
|
||||
'You have reached the hourly limit for Piggy. Try again shortly.',
|
||||
),
|
||||
retryAfterSeconds: decision.retryAfterSeconds,
|
||||
},
|
||||
429,
|
||||
);
|
||||
}
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetchImpl(`${base}/internal/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${options.internalToken}`,
|
||||
@@ -131,26 +259,31 @@ export function createPiggyChatRoutes(options: PiggyChatProxyOptions) {
|
||||
},
|
||||
body: JSON.stringify({ principalUserId: principal.userId, ...parsed.data }),
|
||||
signal: c.req.raw.signal,
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
/*
|
||||
* ECONNREFUSED used to travel all the way to `app.onError` and render as
|
||||
* a red "Internal error" bubble, which reads as "Piggy broke on your
|
||||
* question" rather than "Piggy is not running". A client abort lands
|
||||
* here too — nobody is reading that response, but marking the service
|
||||
* down over it would grey out the dock for everyone for ten seconds, so
|
||||
* only a genuine transport failure invalidates the health cache.
|
||||
*/
|
||||
if (!c.req.raw.signal.aborted) remember(false);
|
||||
return c.json(apiError('piggy_unavailable', 'Piggy chat is not available.'), 503);
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
await upstream.body?.cancel().catch(() => {});
|
||||
return c.json(
|
||||
{
|
||||
error: 'Piggy chat service did not respond.',
|
||||
code: 'piggy_upstream_error',
|
||||
},
|
||||
apiError('piggy_upstream_error', 'Piggy chat service did not respond.'),
|
||||
502,
|
||||
);
|
||||
}
|
||||
const upstreamBody = upstream.body;
|
||||
if (!upstreamBody) {
|
||||
return c.json(
|
||||
{
|
||||
error: 'Piggy chat service returned no response stream.',
|
||||
code: 'piggy_upstream_error',
|
||||
},
|
||||
apiError('piggy_upstream_error', 'Piggy chat service returned no response stream.'),
|
||||
502,
|
||||
);
|
||||
}
|
||||
@@ -174,3 +307,16 @@ export function createPiggyChatRoutes(options: PiggyChatProxyOptions) {
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* `requireReadCapability` in the same shape, but returning rather than
|
||||
* throwing. These routes answer with `c.json` and are mounted in tests without
|
||||
* the app's `onError`, so an AuthError here would surface as a 500 in exactly
|
||||
* the place a 403 is being asserted.
|
||||
*/
|
||||
function holdsReadCapability(principal: Principal, capability: ReadCapability): boolean {
|
||||
return (
|
||||
principal.scopes.includes('read') &&
|
||||
permissionGranted(resolveReadPermissionGrants(principal), capability)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
* load-bearing: Hono runs matched handlers in registration order, so a guard
|
||||
* registered after its handler never runs.
|
||||
*/
|
||||
import type { ReadCapability } from '@pig/core';
|
||||
import type {
|
||||
PiggyChatContext,
|
||||
PiggyPageRoute,
|
||||
PiggyRecordType,
|
||||
ReadCapability,
|
||||
} from '@pig/core';
|
||||
import { Hono } from 'hono';
|
||||
import { readGuard } from '../lib/read-guard';
|
||||
import type { ApiEnv } from '../lib/mutation';
|
||||
@@ -23,6 +28,9 @@ export interface ReadRule {
|
||||
capability: ReadCapability;
|
||||
}
|
||||
|
||||
/** Spelled once: the row below and the relay's own check must never diverge. */
|
||||
export const PIGGY_CHAT_PATH = '/api/piggy/chat';
|
||||
|
||||
/**
|
||||
* `economics:read` covers anything carrying supplier cost, break-even price or
|
||||
* a margin total. `/api/capacity/match` is a POST only because a requirement
|
||||
@@ -51,8 +59,87 @@ export const READ_RULES: readonly ReadRule[] = [
|
||||
{ method: 'GET', path: '/api/facts', capability: 'book:read' },
|
||||
|
||||
{ method: 'GET', path: '/api/team', capability: 'team:read' },
|
||||
|
||||
/**
|
||||
* The assistant reads the book on your behalf, so it is a read.
|
||||
*
|
||||
* `book:read` is the FLOOR, not the whole answer: what a turn may reach is
|
||||
* decided by the context in the body, which a path-keyed table cannot see.
|
||||
* `piggyContextCapability` below is the rest of the policy and the relay
|
||||
* applies it after parsing. The row still earns its place — it puts the chat
|
||||
* POST under the same generic denials as every other read (no team, a
|
||||
* write-only credential) and under read-governance.test.ts with them.
|
||||
*/
|
||||
{ method: 'POST', path: PIGGY_CHAT_PATH, capability: 'book:read' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Which capability a Piggy turn requires, decided by what its context reads.
|
||||
*
|
||||
* The hole this closes: the relay used to check the `read` SCOPE and nothing
|
||||
* else, so a viewer correctly 403'd on `GET /api/capacity/margin` could open
|
||||
* the dock on /margin and have `pig_get_margin_summary` read back book
|
||||
* revenue, supplier cost and break-even. Scope is a property of the
|
||||
* credential; this is the property of the person, and it has to be checked in
|
||||
* the same request.
|
||||
*
|
||||
* The classification is "what does this context's grounding tool return",
|
||||
* never "what does the page look like". `/accounts` sits in the economics
|
||||
* column because its tool is `pig_get_workspace_summary`, which returns book
|
||||
* revenue, cost and gross margin — gating it on `book:read` would hand the
|
||||
* cost book to anyone willing to ask about accounts instead of margin. The
|
||||
* same reasoning puts `commitment`, `supply_deal` and `demand_deal` there:
|
||||
* their reads reach `capacity_commitments` and `allocations`, which
|
||||
* `/api/commitments` and `/api/allocations` already gate as economics.
|
||||
*
|
||||
* If that feels too wide for /accounts or /learn, the fix is in
|
||||
* `apps/piggy/src/page-routes.ts` — give those pages a summary tool that
|
||||
* carries no cost — not a looser row here.
|
||||
*
|
||||
* Both tables are exhaustive on purpose. A context added to @pig/core without
|
||||
* a capability is a door nobody classified, and the compiler refusing it is
|
||||
* cheaper than discovering it in an audit.
|
||||
*/
|
||||
const PIGGY_PAGE_CAPABILITIES: Readonly<Record<PiggyPageRoute, ReadCapability>> = {
|
||||
// pig_get_workspace_summary — book revenue, cost, gross margin, worst idle.
|
||||
'/': 'economics:read',
|
||||
'/accounts': 'economics:read',
|
||||
'/imports': 'economics:read',
|
||||
'/team': 'economics:read',
|
||||
'/facts': 'economics:read',
|
||||
'/learn': 'economics:read',
|
||||
'/settings': 'economics:read',
|
||||
'/piggy': 'economics:read',
|
||||
// pig_get_margin_summary / pig_get_idle_capacity — cost and break-even.
|
||||
'/margin': 'economics:read',
|
||||
'/capacity': 'economics:read',
|
||||
// pig_get_pipeline and pig_get_calendar_ahead: deal values and dates, which
|
||||
// is the book every member already reads.
|
||||
'/growth': 'book:read',
|
||||
'/demand': 'book:read',
|
||||
'/supply': 'book:read',
|
||||
'/calendar': 'book:read',
|
||||
'/contracts': 'book:read',
|
||||
};
|
||||
|
||||
const PIGGY_RECORD_CAPABILITIES: Readonly<Record<PiggyRecordType, ReadCapability>> = {
|
||||
account: 'book:read',
|
||||
contact: 'book:read',
|
||||
contract: 'book:read',
|
||||
demand_deal: 'economics:read',
|
||||
supply_deal: 'economics:read',
|
||||
commitment: 'economics:read',
|
||||
};
|
||||
|
||||
export function piggyContextCapability(context: PiggyChatContext | undefined): ReadCapability {
|
||||
// No context is the dashboard by another name — `createInteractivePigTools`
|
||||
// maps it to '/' — so it must not be the cheap way past the margin gate.
|
||||
if (!context) return PIGGY_PAGE_CAPABILITIES['/'];
|
||||
return context.type === 'page'
|
||||
? PIGGY_PAGE_CAPABILITIES[context.route]
|
||||
: PIGGY_RECORD_CAPABILITIES[context.type];
|
||||
}
|
||||
|
||||
export function createReadGuardRoutes(rules: readonly ReadRule[] = READ_RULES): Hono<ApiEnv> {
|
||||
const routes = new Hono<ApiEnv>();
|
||||
for (const rule of rules) routes.on(rule.method, rule.path, readGuard(rule.capability));
|
||||
|
||||
@@ -107,14 +107,25 @@ export interface CalendarProjection {
|
||||
/**
|
||||
* Where the front end should go when an event is clicked.
|
||||
*
|
||||
* There is no record-detail route convention in this app yet — every page is
|
||||
* flat — so the page is the load-bearing half and the query parameter is a
|
||||
* hint the detail sheet can honour once one exists.
|
||||
* Most pages are still flat, so the page is the load-bearing half and the
|
||||
* query parameter is a hint the detail sheet can honour once one exists.
|
||||
*/
|
||||
function href(page: string, param: string, id: string): string {
|
||||
return `/${page}?${param}=${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accounts are the exception: `/accounts/:id` is a real detail route.
|
||||
*
|
||||
* A compliance deadline is read on the Overview, where the row names the
|
||||
* counterparty and the control says "Review". Sending that to `/accounts` with
|
||||
* an id nothing reads dropped the reader in front of twenty-three unfiltered
|
||||
* rows and left them to find the one the alert had just named.
|
||||
*/
|
||||
function accountHref(id: string): string {
|
||||
return `/accounts/${id}`;
|
||||
}
|
||||
|
||||
/** Drizzle returns numeric columns as strings; `probability` is one of them. */
|
||||
function numeric(value: string | null): number | null {
|
||||
if (value === null) return null;
|
||||
@@ -831,7 +842,7 @@ export class CalendarService {
|
||||
currency: null,
|
||||
recordType: 'export_authorization',
|
||||
recordId: authorization.id,
|
||||
href: href('accounts', 'account', authorization.accountId),
|
||||
href: accountHref(authorization.accountId),
|
||||
meta: {
|
||||
authorizationType: authorization.authorizationType,
|
||||
reference: authorization.reference,
|
||||
@@ -878,7 +889,7 @@ export class CalendarService {
|
||||
currency: null,
|
||||
recordType: 'compliance_artifact',
|
||||
recordId: artifact.id,
|
||||
href: href('accounts', 'account', artifact.accountId),
|
||||
href: accountHref(artifact.accountId),
|
||||
meta: {
|
||||
claim: artifact.claim,
|
||||
scope: artifact.scope,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import test from 'node:test';
|
||||
import { Hono } from 'hono';
|
||||
import { platformSettings, teamMemberships, users, type Database } from '@pig/db';
|
||||
@@ -21,6 +23,13 @@ const principal: Principal = {
|
||||
scopes: ['read', 'write'],
|
||||
};
|
||||
|
||||
/** Below `member`, so `economics:read` is refused and `book:read` is not. */
|
||||
const viewer: Principal = {
|
||||
...principal,
|
||||
userId: '10000000-0000-4000-8000-000000000002',
|
||||
teams: [{ team: 'demand', role: 'viewer' }],
|
||||
};
|
||||
|
||||
function appFor(
|
||||
fetchImpl: typeof fetch,
|
||||
identity: Principal = principal,
|
||||
@@ -50,9 +59,29 @@ const ndjson = () =>
|
||||
headers: { 'content-type': 'application/x-ndjson' },
|
||||
});
|
||||
|
||||
/**
|
||||
* A chat server that answers the health probe.
|
||||
*
|
||||
* Every route now probes `/internal/health` before it will relay anything, so
|
||||
* a fake that answers only `/internal/chat` makes the relay correctly decide
|
||||
* the service is down and 503 the test it was meant to support.
|
||||
*/
|
||||
function relay(chat: typeof fetch = async () => ndjson()): typeof fetch {
|
||||
return async (input, init) => {
|
||||
if (String(input).endsWith('/internal/health')) return new Response('{"ok":true}');
|
||||
return chat(input, init);
|
||||
};
|
||||
}
|
||||
|
||||
/** Refuses to relay at all: what a dead or key-less Piggy process looks like. */
|
||||
const unhealthy: typeof fetch = async (input, init) => {
|
||||
if (String(input).endsWith('/internal/health')) return new Response('', { status: 503 });
|
||||
return relay()(input, init);
|
||||
};
|
||||
|
||||
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) => {
|
||||
const fetchImpl = relay(async (input, init) => {
|
||||
assert.equal(String(input), 'http://127.0.0.1:8931/internal/chat');
|
||||
assert.equal(
|
||||
new Headers(init?.headers).get('authorization'),
|
||||
@@ -64,7 +93,7 @@ test('the authenticated proxy forwards bounded identity and relays NDJSON unchan
|
||||
`${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',
|
||||
@@ -100,10 +129,10 @@ test('the authenticated proxy forwards bounded identity and relays NDJSON unchan
|
||||
test('a credential without read scope never reaches the internal service', async () => {
|
||||
let fetched = false;
|
||||
const app = appFor(
|
||||
async () => {
|
||||
relay(async () => {
|
||||
fetched = true;
|
||||
return new Response();
|
||||
},
|
||||
return ndjson();
|
||||
}),
|
||||
{ ...principal, scopes: ['write'] },
|
||||
);
|
||||
const response = await app.request('/api/piggy/chat', {
|
||||
@@ -117,10 +146,12 @@ test('a credential without read scope never reaches the internal service', async
|
||||
|
||||
test('a docked page context reaches the chat service unaltered', async () => {
|
||||
let forwarded: Record<string, unknown> | undefined;
|
||||
const app = appFor(async (_input, init) => {
|
||||
forwarded = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||
return ndjson();
|
||||
});
|
||||
const app = appFor(
|
||||
relay(async (_input, init) => {
|
||||
forwarded = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
||||
return ndjson();
|
||||
}),
|
||||
);
|
||||
const response = await app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
@@ -142,10 +173,12 @@ test('a docked page context reaches the chat service unaltered', async () => {
|
||||
// shape in front of the model rather than failing at the boundary.
|
||||
test('a page context may not smuggle a record id, and an unknown route is refused', async () => {
|
||||
let fetched = false;
|
||||
const app = appFor(async () => {
|
||||
fetched = true;
|
||||
return ndjson();
|
||||
});
|
||||
const app = appFor(
|
||||
relay(async () => {
|
||||
fetched = true;
|
||||
return ndjson();
|
||||
}),
|
||||
);
|
||||
|
||||
for (const context of [
|
||||
{ type: 'page', route: '/not-a-page' },
|
||||
@@ -167,10 +200,10 @@ test('the stored admin toggle disables chat without the environment changing', a
|
||||
let fetched = false;
|
||||
let piggyEnabled = true;
|
||||
const app = appFor(
|
||||
async () => {
|
||||
relay(async () => {
|
||||
fetched = true;
|
||||
return ndjson();
|
||||
},
|
||||
}),
|
||||
principal,
|
||||
{ resolvePiggyEnabled: async () => piggyEnabled },
|
||||
);
|
||||
@@ -197,7 +230,7 @@ test('the stored admin toggle disables chat without the environment changing', a
|
||||
// Losing the settings row must degrade to the environment gate. A dock on
|
||||
// every page turns one failed query into a site-wide outage otherwise.
|
||||
test('an unreadable settings row falls back to the environment gate', async () => {
|
||||
const app = appFor(async () => ndjson(), principal, {
|
||||
const app = appFor(relay(), principal, {
|
||||
resolvePiggyEnabled: async () => {
|
||||
throw new Error('platform settings unavailable');
|
||||
},
|
||||
@@ -209,7 +242,7 @@ test('an unreadable settings row falls back to the environment gate', async () =
|
||||
});
|
||||
|
||||
test('the environment gate still overrides a stored toggle that says yes', async () => {
|
||||
const app = appFor(async () => ndjson(), principal, {
|
||||
const app = appFor(relay(), principal, {
|
||||
enabled: false,
|
||||
resolvePiggyEnabled: async () => true,
|
||||
});
|
||||
@@ -219,6 +252,302 @@ test('the environment gate still overrides a stored toggle that says yes', async
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read authorisation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The hole this suite exists for.
|
||||
*
|
||||
* A demand VIEWER is correctly 403'd on `GET /api/capacity/margin` by
|
||||
* `READ_RULES`. Before this, the same person could open the dock on /margin
|
||||
* and have `pig_get_margin_summary` read back book revenue, supplier cost and
|
||||
* break-even — because the relay checked the credential's `read` scope and
|
||||
* never the person's capability, and the chat server receives a bare user id
|
||||
* with no memberships attached to check.
|
||||
*/
|
||||
async function chatWith(
|
||||
identity: Principal,
|
||||
context: unknown,
|
||||
onFetch: () => void = () => {},
|
||||
) {
|
||||
const app = appFor(
|
||||
relay(async () => {
|
||||
onFetch();
|
||||
return ndjson();
|
||||
}),
|
||||
identity,
|
||||
);
|
||||
return app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(context === undefined ? { message: 'Go on.' } : { message: 'Go on.', context }),
|
||||
});
|
||||
}
|
||||
|
||||
test('a viewer cannot reach the cost book through the dock', async () => {
|
||||
let fetched = false;
|
||||
const denied = [
|
||||
{ type: 'page', route: '/margin' },
|
||||
{ type: 'page', route: '/capacity' },
|
||||
{ type: 'page', route: '/' },
|
||||
// The workspace summary carries book margin, so the page it is served on
|
||||
// does not make it cheaper to read.
|
||||
{ type: 'page', route: '/accounts' },
|
||||
{ type: 'commitment', id: '20000000-0000-4000-8000-000000000003' },
|
||||
// No context at all is the dashboard by another name, and must not be the
|
||||
// way round the gate.
|
||||
undefined,
|
||||
];
|
||||
|
||||
for (const context of denied) {
|
||||
const response = await chatWith(viewer, context, () => {
|
||||
fetched = true;
|
||||
});
|
||||
assert.equal(response.status, 403, JSON.stringify(context));
|
||||
assert.equal(
|
||||
((await response.json()) as { code: string }).code,
|
||||
'insufficient_permission',
|
||||
JSON.stringify(context),
|
||||
);
|
||||
}
|
||||
assert.equal(fetched, false);
|
||||
});
|
||||
|
||||
test('a viewer still reaches the book contexts they can already read', async () => {
|
||||
for (const context of [
|
||||
{ type: 'page', route: '/demand' },
|
||||
{ type: 'page', route: '/contracts' },
|
||||
{ type: 'account', id: '20000000-0000-4000-8000-000000000004' },
|
||||
]) {
|
||||
const response = await chatWith(viewer, context);
|
||||
assert.equal(response.status, 200, JSON.stringify(context));
|
||||
}
|
||||
});
|
||||
|
||||
test('a research lead reads the book but not the margin dock', async () => {
|
||||
const researcher: Principal = { ...viewer, teams: [{ team: 'research', role: 'lead' }] };
|
||||
assert.equal((await chatWith(researcher, { type: 'page', route: '/demand' })).status, 200);
|
||||
assert.equal((await chatWith(researcher, { type: 'page', route: '/margin' })).status, 403);
|
||||
});
|
||||
|
||||
test('a commercial member keeps the margin dock', async () => {
|
||||
assert.equal((await chatWith(principal, { type: 'page', route: '/margin' })).status, 200);
|
||||
});
|
||||
|
||||
test('status tells a viewer the dock is usable and a stranger that it is not', async () => {
|
||||
const stranger: Principal = { ...viewer, teams: [] };
|
||||
assert.deepEqual(await (await appFor(relay(), viewer).request('/api/piggy/status')).json(), {
|
||||
enabled: true,
|
||||
canUse: true,
|
||||
});
|
||||
assert.deepEqual(await (await appFor(relay(), stranger).request('/api/piggy/status')).json(), {
|
||||
enabled: true,
|
||||
canUse: false,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rate limiting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('a user is capped per hour and told how long to wait', async () => {
|
||||
let relayed = 0;
|
||||
const app = appFor(
|
||||
relay(async () => {
|
||||
relayed += 1;
|
||||
return ndjson();
|
||||
}),
|
||||
principal,
|
||||
{ messagesPerHour: 2 },
|
||||
);
|
||||
const send = () =>
|
||||
app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Again.', context: { type: 'page', route: '/margin' } }),
|
||||
});
|
||||
|
||||
assert.equal((await send()).status, 200);
|
||||
assert.equal((await send()).status, 200);
|
||||
|
||||
const limited = await send();
|
||||
assert.equal(limited.status, 429);
|
||||
const body = (await limited.json()) as { code: string; retryAfterSeconds: number };
|
||||
assert.equal(body.code, 'piggy_rate_limited');
|
||||
assert.ok(body.retryAfterSeconds > 0);
|
||||
assert.equal(limited.headers.get('retry-after'), String(body.retryAfterSeconds));
|
||||
// The quota is a spend limit, so nothing past it may reach inference.
|
||||
assert.equal(relayed, 2);
|
||||
});
|
||||
|
||||
/**
|
||||
* Keyed on the user, not the address. Everyone in one office shares an
|
||||
* `X-Forwarded-For`, and one colleague exhausting the credit for the floor is
|
||||
* the failure an address key would produce.
|
||||
*/
|
||||
test('one user exhausting the quota does not silence another', async () => {
|
||||
const routes = createPiggyChatRoutes({
|
||||
enabled: true,
|
||||
internalUrl: 'http://127.0.0.1:8931',
|
||||
internalToken: 'internal-token-with-at-least-32-characters',
|
||||
fetchImpl: relay(),
|
||||
messagesPerHour: 1,
|
||||
});
|
||||
const app = new Hono<ApiEnv>();
|
||||
let identity = principal;
|
||||
app.use('*', async (context, next) => {
|
||||
context.set('principal', identity);
|
||||
await next();
|
||||
});
|
||||
app.route('/', routes);
|
||||
const send = () =>
|
||||
app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Again.' }),
|
||||
});
|
||||
|
||||
assert.equal((await send()).status, 200);
|
||||
assert.equal((await send()).status, 429);
|
||||
|
||||
identity = { ...principal, userId: '10000000-0000-4000-8000-000000000009' };
|
||||
assert.equal((await send()).status, 200);
|
||||
});
|
||||
|
||||
test('a refused request does not spend the quota it was never going to use', async () => {
|
||||
const app = appFor(relay(), viewer, { messagesPerHour: 1 });
|
||||
const send = (route: string) =>
|
||||
app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Again.', context: { type: 'page', route } }),
|
||||
});
|
||||
|
||||
assert.equal((await send('/margin')).status, 403);
|
||||
assert.equal((await send('/margin')).status, 403);
|
||||
// The one message they are entitled to is still there.
|
||||
assert.equal((await send('/demand')).status, 200);
|
||||
assert.equal((await send('/demand')).status, 429);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Availability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('a dead chat server is reported as unavailable rather than usable', async () => {
|
||||
const app = appFor(unhealthy);
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: false,
|
||||
canUse: false,
|
||||
});
|
||||
const response = await app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Anyone there?' }),
|
||||
});
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(((await response.json()) as { code: string }).code, 'piggy_unavailable');
|
||||
});
|
||||
|
||||
/**
|
||||
* The bug in its original form: `configured` is true, the probe is cached
|
||||
* healthy, and then the socket is refused. That rejection used to reach
|
||||
* `app.onError` and render as a red "Internal error" bubble, which reads as
|
||||
* "Piggy broke on your question" rather than "Piggy is not running".
|
||||
*/
|
||||
test('a connection failure mid-request becomes the clean 503, not an internal error', async () => {
|
||||
const app = appFor(
|
||||
relay(async () => {
|
||||
throw Object.assign(new Error('fetch failed'), { code: 'ECONNREFUSED' });
|
||||
}),
|
||||
);
|
||||
const response = await app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Anyone there?' }),
|
||||
});
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(((await response.json()) as { code: string }).code, 'piggy_unavailable');
|
||||
|
||||
// And the status endpoint stops lying immediately, rather than after the
|
||||
// health cache expires.
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: false,
|
||||
canUse: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('a genuinely unreachable port 503s without an injected fetch', async () => {
|
||||
const closed = createServer();
|
||||
await new Promise<void>((resolve) => closed.listen(0, '127.0.0.1', resolve));
|
||||
const port = (closed.address() as AddressInfo).port;
|
||||
await new Promise<void>((resolve) => closed.close(() => resolve()));
|
||||
|
||||
const app = new Hono<ApiEnv>();
|
||||
app.use('*', async (context, next) => {
|
||||
context.set('principal', principal);
|
||||
await next();
|
||||
});
|
||||
app.route(
|
||||
'/',
|
||||
createPiggyChatRoutes({
|
||||
enabled: true,
|
||||
internalUrl: `http://127.0.0.1:${port}`,
|
||||
internalToken: 'internal-token-with-at-least-32-characters',
|
||||
}),
|
||||
);
|
||||
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: false,
|
||||
canUse: false,
|
||||
});
|
||||
const response = await app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Anyone there?' }),
|
||||
});
|
||||
assert.equal(response.status, 503);
|
||||
assert.equal(((await response.json()) as { code: string }).code, 'piggy_unavailable');
|
||||
});
|
||||
|
||||
// A dock on every page means a status call on every navigation; probing the
|
||||
// chat server on each one would be a loopback flood for no extra truth.
|
||||
test('the health probe is cached and never runs concurrently', async () => {
|
||||
let probes = 0;
|
||||
const app = appFor(async (input) => {
|
||||
if (String(input).endsWith('/internal/health')) {
|
||||
probes += 1;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return ndjson();
|
||||
});
|
||||
|
||||
await Promise.all(Array.from({ length: 8 }, () => app.request('/api/piggy/status')));
|
||||
assert.equal(probes, 1);
|
||||
await app.request('/api/piggy/status');
|
||||
assert.equal(probes, 1);
|
||||
});
|
||||
|
||||
test('a stale health verdict is re-probed once the cache lapses', async () => {
|
||||
let probes = 0;
|
||||
const app = appFor(
|
||||
async (input) => {
|
||||
if (String(input).endsWith('/internal/health')) {
|
||||
probes += 1;
|
||||
return new Response('{"ok":true}');
|
||||
}
|
||||
return ndjson();
|
||||
},
|
||||
principal,
|
||||
{ healthCacheMs: 0 },
|
||||
);
|
||||
|
||||
await app.request('/api/piggy/status');
|
||||
await app.request('/api/piggy/status');
|
||||
assert.equal(probes, 2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Composition
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -232,7 +561,10 @@ test('the environment gate still overrides a stored toggle that says yes', async
|
||||
* pointless test of Drizzle. Anything the app queries beyond these three
|
||||
* tables comes back empty, which is what an untouched deployment looks like.
|
||||
*/
|
||||
function stubDatabase(store: { piggyEnabled: boolean }): Database {
|
||||
function stubDatabase(
|
||||
store: { piggyEnabled: boolean },
|
||||
memberships: Record<string, unknown>[] = [{ team: 'demand', role: 'member' }],
|
||||
): Database {
|
||||
const rowsFor = (table: unknown): Record<string, unknown>[] => {
|
||||
if (table === users) {
|
||||
return [
|
||||
@@ -245,7 +577,7 @@ function stubDatabase(store: { piggyEnabled: boolean }): Database {
|
||||
},
|
||||
];
|
||||
}
|
||||
if (table === teamMemberships) return [{ team: 'demand', role: 'member' }];
|
||||
if (table === teamMemberships) return memberships;
|
||||
if (table === platformSettings) return [{ piggyEnabled: store.piggyEnabled }];
|
||||
return [];
|
||||
};
|
||||
@@ -272,6 +604,22 @@ function stubDatabase(store: { piggyEnabled: boolean }): Database {
|
||||
} as unknown as Database;
|
||||
}
|
||||
|
||||
/** A chat server that is up, on a port nothing else in the suite is using. */
|
||||
async function healthServer(): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
const server: Server = createServer((request, response) => {
|
||||
if (request.url === '/internal/health') {
|
||||
response.writeHead(200, { 'content-type': 'application/json' }).end('{"ok":true}');
|
||||
return;
|
||||
}
|
||||
response.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
return {
|
||||
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
||||
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression this file could not previously catch.
|
||||
*
|
||||
@@ -282,27 +630,62 @@ function stubDatabase(store: { piggyEnabled: boolean }): Database {
|
||||
* stored setting is consulted, so this one goes through `createApp`.
|
||||
*/
|
||||
test('createApp wires the stored toggle into the chat routes', async () => {
|
||||
const store = { piggyEnabled: false };
|
||||
const config = loadConfig({
|
||||
NODE_ENV: 'development',
|
||||
DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig',
|
||||
PIGGY_ENABLED: 'true',
|
||||
PIGGY_INTERNAL_URL: 'http://127.0.0.1:8931',
|
||||
PIGGY_INTERNAL_TOKEN: 'internal-token-with-at-least-32-characters',
|
||||
});
|
||||
// Null provider is the development path: no token, principal comes from the
|
||||
// first user in the table. What is under test is the toggle, not the auth.
|
||||
const app = createApp(config, stubDatabase(store), null);
|
||||
const piggy = await healthServer();
|
||||
try {
|
||||
const store = { piggyEnabled: false };
|
||||
const config = loadConfig({
|
||||
NODE_ENV: 'development',
|
||||
DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig',
|
||||
PIGGY_ENABLED: 'true',
|
||||
PIGGY_INTERNAL_URL: piggy.url,
|
||||
PIGGY_INTERNAL_TOKEN: 'internal-token-with-at-least-32-characters',
|
||||
});
|
||||
// Null provider is the development path: no token, principal comes from the
|
||||
// first user in the table. What is under test is the toggle, not the auth.
|
||||
const app = createApp(config, stubDatabase(store), null);
|
||||
|
||||
assert.equal(config.PIGGY_ENABLED, true);
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: false,
|
||||
canUse: false,
|
||||
});
|
||||
assert.equal(config.PIGGY_ENABLED, true);
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: false,
|
||||
canUse: false,
|
||||
});
|
||||
|
||||
store.piggyEnabled = true;
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: true,
|
||||
canUse: true,
|
||||
});
|
||||
store.piggyEnabled = true;
|
||||
assert.deepEqual(await (await app.request('/api/piggy/status')).json(), {
|
||||
enabled: true,
|
||||
canUse: true,
|
||||
});
|
||||
} finally {
|
||||
await piggy.close();
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* The read guard is mounted before every feature route in `createApp`, and the
|
||||
* chat POST now has a row in that table. This proves the composed app refuses
|
||||
* the turn before the relay is even reached — the relay's own capability check
|
||||
* is the one that can see the context, and this is the floor beneath it.
|
||||
*/
|
||||
test('createApp governs the chat POST with the read guard as well', async () => {
|
||||
const piggy = await healthServer();
|
||||
try {
|
||||
const config = loadConfig({
|
||||
NODE_ENV: 'development',
|
||||
DATABASE_URL: 'postgres://pig:pig@localhost:5432/pig',
|
||||
PIGGY_ENABLED: 'true',
|
||||
PIGGY_INTERNAL_URL: piggy.url,
|
||||
PIGGY_INTERNAL_TOKEN: 'internal-token-with-at-least-32-characters',
|
||||
});
|
||||
// On no team, so no read capability at all — the case the guard exists for.
|
||||
const app = createApp(config, stubDatabase({ piggyEnabled: true }, []), null);
|
||||
|
||||
const response = await app.request('/api/piggy/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ message: 'Show me the book.' }),
|
||||
});
|
||||
assert.equal(response.status, 403);
|
||||
} finally {
|
||||
await piggy.close();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user