diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 60c059c..e6920aa 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -64,6 +64,7 @@ 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 { createGrowthRoutes } from './routes/growth'; import { NotificationOutbox } from './services/notification-outbox'; type Env = { Variables: { principal: Principal } }; @@ -218,6 +219,7 @@ export function createApp( publicUrl: config.PIG_PUBLIC_URL, })); app.route('/', createContractRoutes(db)); + app.route('/', createGrowthRoutes(db)); app.route( '/', createPiggyChatRoutes({ diff --git a/apps/api/src/routes/growth.ts b/apps/api/src/routes/growth.ts index 3b065e3..e12faf9 100644 --- a/apps/api/src/routes/growth.ts +++ b/apps/api/src/routes/growth.ts @@ -1,5 +1,5 @@ import type { Database } from '@pig/db'; -import { Hono } from 'hono'; +import { Hono, type Context } from 'hono'; import { z } from 'zod'; import type { ApiEnv } from '../lib/mutation'; import { apiError } from '../lib/mutation'; @@ -7,12 +7,29 @@ import { CustomerLifecycleService } from '../services/customer-lifecycle'; const accountIdSchema = z.string().uuid(); +export function growthReadAllowed(scopes: readonly string[]): boolean { + return scopes.includes('read'); +} + +function requireGrowthRead(context: Context) { + if (growthReadAllowed(context.get('principal').scopes)) return null; + return context.json( + apiError('insufficient_scope', "This credential lacks the 'read' scope."), + 403, + ); +} + export function createGrowthRoutes(db: Database): Hono { const routes = new Hono(); const service = new CustomerLifecycleService(db); - routes.get('/api/growth', async (context) => context.json(await service.report())); + routes.get('/api/growth', async (context) => { + const denied = requireGrowthRead(context); + return denied ?? context.json(await service.report()); + }); routes.get('/api/growth/accounts/:id', async (context) => { + const denied = requireGrowthRead(context); + if (denied) return denied; const accountId = accountIdSchema.safeParse(context.req.param('id')); if (!accountId.success) { return context.json(apiError('invalid_account', 'Invalid account ID.', accountId.error.issues), 400); diff --git a/apps/api/src/routes/piggy-chat.ts b/apps/api/src/routes/piggy-chat.ts index ba6780f..c7b8603 100644 --- a/apps/api/src/routes/piggy-chat.ts +++ b/apps/api/src/routes/piggy-chat.ts @@ -93,10 +93,10 @@ export function createPiggyChatRoutes(options: PiggyChatProxyOptions) { ); if (!upstream.ok) { - const detail = await upstream.text().catch(() => ''); + await upstream.body?.cancel().catch(() => {}); return c.json( { - error: detail.slice(0, 500) || 'Piggy chat service did not respond.', + error: 'Piggy chat service did not respond.', code: 'piggy_upstream_error', }, 502, diff --git a/apps/api/src/services/customer-lifecycle.ts b/apps/api/src/services/customer-lifecycle.ts index f29b6da..feb7f90 100644 --- a/apps/api/src/services/customer-lifecycle.ts +++ b/apps/api/src/services/customer-lifecycle.ts @@ -50,6 +50,7 @@ export interface GrowthReport { rulesetVersion: string; computedAt: string; customers: GrowthCustomer[]; + customersTruncated: boolean; idleSupply: GrowthIdleSupply[]; } @@ -63,7 +64,7 @@ export class CustomerLifecycleService { this.capacity = new CapacityService(db); } - async report(): Promise { + async report(accountId?: string): Promise { const now = this.clock(); const accountRows = await this.db .select({ @@ -75,13 +76,25 @@ export class CustomerLifecycleService { lastActivityAt: accounts.lastActivityAt, }) .from(accounts) - .where(and(or(eq(accounts.side, 'demand'), eq(accounts.side, 'both')), isNull(accounts.archivedAt))) + .where(and( + or(eq(accounts.side, 'demand'), eq(accounts.side, 'both')), + isNull(accounts.archivedAt), + accountId ? eq(accounts.id, accountId) : undefined, + )) .orderBy(desc(accounts.updatedAt)) - .limit(200); + .limit(accountId ? 1 : 201); + const customersTruncated = accountRows.length > 200; + if (customersTruncated) accountRows.length = 200; const accountIds = accountRows.map((account) => account.id); if (!accountIds.length) { const idleSupply = await this.readIdleSupply(); - return { rulesetVersion: 'growth-r1-2026-08-13', computedAt: now.toISOString(), customers: [], idleSupply }; + return { + rulesetVersion: 'growth-r1-2026-08-13', + computedAt: now.toISOString(), + customers: [], + customersTruncated: false, + idleSupply, + }; } const [dealRows, contractRows, activityRows] = await Promise.all([ @@ -164,12 +177,13 @@ export class CustomerLifecycleService { rulesetVersion: customers[0]?.lifecycle.rulesetVersion ?? 'growth-r1-2026-08-13', computedAt: now.toISOString(), customers, + customersTruncated, idleSupply, }; } async account(accountId: string): Promise { - const report = await this.report(); + const report = await this.report(accountId); return report.customers.find((customer) => customer.account.id === accountId) ?? null; } diff --git a/apps/api/test/growth.test.ts b/apps/api/test/growth.test.ts new file mode 100644 index 0000000..bd10386 --- /dev/null +++ b/apps/api/test/growth.test.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { growthReadAllowed } from '../src/routes/growth'; + +describe('growth read boundary', () => { + it('requires an explicit read scope instead of treating authentication as authorization', () => { + assert.equal(growthReadAllowed([]), false); + assert.equal(growthReadAllowed(['write']), false); + assert.equal(growthReadAllowed(['read']), true); + assert.equal(growthReadAllowed(['read', 'write']), true); + }); +}); diff --git a/apps/piggy/src/chat-server.ts b/apps/piggy/src/chat-server.ts index 158ced5..3fcd9bc 100644 --- a/apps/piggy/src/chat-server.ts +++ b/apps/piggy/src/chat-server.ts @@ -96,9 +96,10 @@ export function startPiggyChatServer( } response.end(); } catch (error) { - const message = error instanceof Error ? error.message : 'Piggy chat failed.'; + const invalidRequest = error instanceof z.ZodError; + const message = invalidRequest ? 'Invalid Piggy chat request.' : 'Piggy chat failed.'; if (!response.headersSent) { - response.writeHead(error instanceof z.ZodError ? 400 : 500, { + response.writeHead(invalidRequest ? 400 : 500, { 'content-type': 'application/json', }); response.end(JSON.stringify({ error: message })); diff --git a/apps/piggy/src/chat.ts b/apps/piggy/src/chat.ts index 0cb74ef..5c4d9f6 100644 --- a/apps/piggy/src/chat.ts +++ b/apps/piggy/src/chat.ts @@ -153,10 +153,8 @@ export class PrimeOpenAIChatProvider { }); if (!response.ok) { - const body = await response.text().catch(() => ''); - throw new Error( - `Piggy inference ${response.status}: ${body.slice(0, 500) || response.statusText}`, - ); + await response.body?.cancel().catch(() => {}); + throw new Error(`Piggy inference request failed with status ${response.status}.`); } if (!response.body) throw new Error('Piggy inference returned no response stream.'); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index dc2f05e..726e741 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -26,6 +26,7 @@ const FactReview = lazy(() => import('@/pages/FactReview').then(({ FactReview }) const Contracts = lazy(() => import('@/pages/Contracts').then(({ Contracts }) => ({ default: Contracts }))); const Imports = lazy(() => import('@/pages/Imports').then(({ Imports }) => ({ default: Imports }))); const Piggy = lazy(() => import('@/pages/Piggy').then(({ Piggy }) => ({ default: Piggy }))); +const Growth = lazy(() => import('@/pages/Growth').then(({ Growth }) => ({ default: Growth }))); const queryClient = new QueryClient({ defaultOptions: { @@ -168,6 +169,7 @@ function AuthGate({ config }: { config: PublicConfig }) { }> } /> } /> + } /> } /> } /> } /> diff --git a/apps/web/src/components/Shell.tsx b/apps/web/src/components/Shell.tsx index 4eff893..992e856 100644 --- a/apps/web/src/components/Shell.tsx +++ b/apps/web/src/components/Shell.tsx @@ -26,6 +26,7 @@ import { ShieldCheck, Settings, TrendingUp, + Target, Users, } from 'lucide-react'; import { PiggyLogo, PiggyMark } from './PiggyMark'; @@ -39,6 +40,7 @@ interface NavItem extends CommandDestination { const NAV: NavItem[] = [ { to: '/', label: 'Overview', icon: LayoutDashboard, primary: true }, + { to: '/growth', label: 'Growth', icon: Target }, { to: '/piggy', label: 'Piggy', icon: MessageCircleMore }, { to: '/margin', label: 'Margin', icon: TrendingUp, primary: true }, { to: '/capacity', label: 'Capacity', icon: Server, primary: true }, @@ -90,7 +92,7 @@ export function Shell() { end={item.to === '/'} className={({ isActive }) => cn( - 'flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors', + 'flex min-h-[44px] items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors', isActive ? 'bg-accent-subtle text-accent-fg' : 'text-muted hover:bg-surface-2 hover:text-fg', @@ -106,7 +108,7 @@ export function Shell() { type="button" variant="ghost" size="sm" - className="mx-3 mb-3 justify-start text-muted" + className="mx-3 mb-3 min-h-[44px] justify-start text-muted" onClick={() => setCommandOpen(true)} > diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 9dbeaa0..244687f 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -42,7 +42,7 @@ const DialogContent = React.forwardRef< {...props} > {children} - + Close diff --git a/apps/web/src/components/ui/switch.tsx b/apps/web/src/components/ui/switch.tsx index 5f4117f..34be6b7 100644 --- a/apps/web/src/components/ui/switch.tsx +++ b/apps/web/src/components/ui/switch.tsx @@ -11,15 +11,19 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( + diff --git a/apps/web/src/pages/Contracts.tsx b/apps/web/src/pages/Contracts.tsx index fc42bcf..1044708 100644 --- a/apps/web/src/pages/Contracts.tsx +++ b/apps/web/src/pages/Contracts.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { cloneElement, isValidElement, useId, useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { AlertTriangle, @@ -364,13 +364,13 @@ export function Contracts() { value={query} onChange={(event) => setQuery(event.target.value)} /> - setType(value as typeof type)}> + setType(value as typeof type)}> All paper {CONTRACT_TYPES.map((value) => ( {TYPE_LABELS[value]} ))} - setSide(value as typeof side)}> + setSide(value as typeof side)}> Both sides Demand Supply @@ -958,16 +958,29 @@ function formFromDetail(detail: ContractDetail): ContractFormState { }; } -function EnumSelect({ children, ...props }: React.ComponentProps) { - return ; +type EnumSelectProps = React.ComponentProps & { + id?: string; + 'aria-label'?: string; +}; + +function EnumSelect({ children, id, 'aria-label': ariaLabel, ...props }: EnumSelectProps) { + return ; } function Field({ label, hint, className, children }: { label: string; hint?: string; className?: string; children: React.ReactNode }) { - return
{children}{hint ?

{hint}

: null}
; + const id = useId(); + const control = isValidElement<{ id?: string; 'aria-label'?: string }>(children) + ? cloneElement(children, { + id: children.props.id ?? id, + 'aria-label': children.props['aria-label'] ?? label, + }) + : children; + return
{control}{hint ?

{hint}

: null}
; } function ToggleField({ label, checked, onCheckedChange }: { label: string; checked: boolean; onCheckedChange(value: boolean): void }) { - return
; + const id = useId(); + return
; } function Section({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) { diff --git a/apps/web/src/pages/Growth.tsx b/apps/web/src/pages/Growth.tsx index 97ac354..a8d44e6 100644 --- a/apps/web/src/pages/Growth.tsx +++ b/apps/web/src/pages/Growth.tsx @@ -31,6 +31,7 @@ interface GrowthReport { rulesetVersion: string; computedAt: string; customers: GrowthCustomer[]; + customersTruncated: boolean; idleSupply: Array<{ commitmentId: string; name: string; @@ -84,6 +85,12 @@ export function Growth() { + {data.customersTruncated ? ( +
+ Showing the 200 most recently updated demand accounts. Narrow the workspace before treating these totals as complete. +
+ ) : null} +
diff --git a/packages/db/src/seed/demo.ts b/packages/db/src/seed/demo.ts index adc3d97..9083656 100644 --- a/packages/db/src/seed/demo.ts +++ b/packages/db/src/seed/demo.ts @@ -18,7 +18,7 @@ * would be fabricating commercial records about real businesses, which is a * different thing entirely and not worth the realism. * - * Remove it all with `npm run db:demo -- --clear`. + * Remove it all with `pnpm db:demo -- --clear`. * * The numbers are chosen to teach. The book as a whole clears a modest margin — * roughly what this industry actually earns once capacity cost is charged @@ -476,11 +476,16 @@ async function seedDemo() { .where(eq(accounts.domain, 'lambda.ai')) .limit(1); if (lambda) { - await db - .insert(supplyDeals) - .values({ + const LAMBDA_DEAL = `${PREFIX}256× H200 — pricing`; + const [existingLambdaDeal] = await db + .select({ id: supplyDeals.id }) + .from(supplyDeals) + .where(and(eq(supplyDeals.accountId, lambda.id), eq(supplyDeals.name, LAMBDA_DEAL))) + .limit(1); + if (!existingLambdaDeal) { + await db.insert(supplyDeals).values({ accountId: lambda.id, - name: `${PREFIX}256× H200 — pricing`, + name: LAMBDA_DEAL, stage: 'financial_diligence', gpuType: 'H200', gpuCount: 256, @@ -490,8 +495,8 @@ async function seedDemo() { technicalVerdict: 'pass', technicalNotes: 'Fabric verified. Storage throughput per GPU is below spec — flagged.', financialNotes: 'Awaiting a firm quote on the committed tranche.', - }) - .onConflictDoNothing(); + }); + } } // ------------------------------------------------------------ demand side @@ -823,7 +828,7 @@ async function seedDemo() { console.log(` ${factSeeds.length} agent-derived facts (${factsAdded} new) — 2 applied, 4 awaiting review`); console.log(' 6 demand deals across the pipeline, 5 supply deals'); console.log(' Allocations including one unconverted hold and internal research burn'); - console.log('\nEverything is prefixed "DEMO — ". Remove it with: npm run db:demo -- --clear'); + console.log('\nEverything is prefixed "DEMO — ". Remove it with: pnpm db:demo -- --clear'); } const shouldClear = process.argv.includes('--clear');