From 6bd5526675bcd023f22ac0b7ed22514d2d12ace0 Mon Sep 17 00:00:00 2001 From: karti Date: Thu, 13 Aug 2026 02:01:17 -0700 Subject: [PATCH 1/5] Give Card min-w-0 so the page stops scrolling sideways on a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Overview page overflowed 80px at 393px wide. Traced to the "The book" card: the grid column was a correct 361px, the card inside it was 457px and refused to shrink. Confirmed by forcing `min-width: 0` on grid children in the live page, which took the overflow to 0. Fixed on the Card base class rather than at the call site, because this is the third time the same trap has been fixed individually — grid and flex children default to `min-width: auto` and cards routinely hold something unshrinkable, a tabular-nums figure or a nowrap badge. `min-width: 0` is inert for a block-level card outside a flex or grid parent, so applying it always costs nothing and removes the whole class of bug. Verified by running the stack locally against the demo data: 0px overflow across all 12 routes at both 393px and 1440px. AGENTS.md updated to say any NEW container primitive needs the same, with the one-line browser check to confirm it. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 16 +++++++++++++--- apps/cli/src/main.ts | 0 apps/web/src/components/ui/index.tsx | 16 +++++++++++++++- 3 files changed, 28 insertions(+), 4 deletions(-) mode change 100644 => 100755 apps/cli/src/main.ts diff --git a/AGENTS.md b/AGENTS.md index 0430104..47ad03e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,9 +135,19 @@ conflict on, do an existence check instead. flag set to false was silently on. Use the `envBoolean` helper in `apps/api/src/lib/config.ts`. -**Grid children that truncate need `min-w-0`.** Grid items default to -`min-width: auto` and `truncate` sets `nowrap`, so a long title becomes -unshrinkable content and the page scrolls sideways on a phone. +**Grid and flex children need `min-w-0`.** They default to +`min-width: auto`, meaning they refuse to shrink below their content — and a +`tabular-nums` figure, a `whitespace-nowrap` badge or a `truncate` title is all +it takes. The page then scrolls sideways on a phone and nothing reports an +error. This was fixed three separate times at individual call sites before +`Card` was given `min-w-0` on its base class; **any new container primitive +needs the same**. Check with: + +```js +document.documentElement.scrollWidth - document.documentElement.clientWidth +``` + +It should be 0 on every route at 393px wide. **Drizzle-generated migrations are not always valid SQL.** A `jsonb → integer` cast was emitted without the `USING` clause Postgres requires. Always apply a diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts old mode 100644 new mode 100755 diff --git a/apps/web/src/components/ui/index.tsx b/apps/web/src/components/ui/index.tsx index 70c4a95..1822b97 100644 --- a/apps/web/src/components/ui/index.tsx +++ b/apps/web/src/components/ui/index.tsx @@ -83,8 +83,22 @@ Input.displayName = 'Input'; // --------------------------------------------------------------------- card +/** + * `min-w-0` is on the base class deliberately, not left to each call site. + * + * A grid or flex child defaults to `min-width: auto`, meaning it refuses to + * shrink below its content — and cards routinely contain something + * unshrinkable (a `tabular-nums` figure, a `whitespace-nowrap` badge, a long + * unbroken title). The result is a card wider than its column, which drags the + * whole page into horizontal scrolling on a phone. + * + * This has now been fixed three separate times at individual call sites, which + * is the signal that it belongs here instead. `min-width: 0` is inert for a + * block-level card outside a flex or grid container, so applying it always + * costs nothing and removes the entire class of bug. + */ export function Card({ className, ...props }: HTMLAttributes) { - return
; + return
; } export function CardHeader({ className, ...props }: HTMLAttributes) { From 54edee30ed61f3e2419e94f134f72313fc052472 Mon Sep 17 00:00:00 2001 From: karti Date: Thu, 13 Aug 2026 02:05:00 -0700 Subject: [PATCH 2/5] Unknown /api paths returned the SPA with HTTP 200 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An authenticated GET to any unrecognised API route — a typo, a renamed endpoint, an older client — fell through to the SPA fallback and returned 200 text/html containing the app shell. This is close to the worst failure shape for an API consumer. `response.ok` is true, so nothing treats it as an error; the caller then dies on `JSON.parse` with "Unexpected token '<'" far from the actual cause. The MCP server, the CLI and Piggy all consume this API and would all have hit it. It was masked from casual testing because unauthenticated requests are rejected earlier by the auth middleware, so it only appears once you hold a valid token. Found by probing production with Scott's token: GET /api/keys (the real path is /api/api-keys) returned 200 text/html. The static-file middleware already carried this guard — added for the same reason when og.png was being served as HTML — but the SPA fallback beneath it did not. Same guard, one place missing. Verified: unknown API paths now return 404 application/json, real API paths still answer, client-side routes still receive the shell, and static assets still serve with their own content types. Typecheck clean, 124 unit tests and the e2e suite green. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 7 +++++++ apps/api/src/server.ts | 22 +++++++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 47ad03e..6d69968 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -165,6 +165,13 @@ Verified: 1× A100 at 1.79, 2× A100 at 3.58. `gpuMemory` is likewise a node total. There is an open bug for this — the mapper currently stores both as if per-GPU, so an 8-GPU node reads eight times too expensive. +**The SPA fallback must never answer an `/api/` path.** Without an explicit +guard, an unknown API route returns `200 text/html` — the app shell — and the +caller sees `response.ok === true` before failing on `JSON.parse` with +"Unexpected token '<'", a long way from the cause. Both the static-file +middleware and the SPA fallback in `apps/api/src/server.ts` carry the guard; +anything added after them needs it too. + **Prime Intellect has two API hosts.** `api.primeintellect.ai` is compute and pods. Inference is `api.pinference.ai/api/v1`, OpenAI-compatible. diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 3c510bc..46ec14b 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -57,9 +57,25 @@ if (existsSync(webDist)) { return serveStatic({ root: './apps/web/dist' })(c, next); }); - // Client-side routes (/margin, /capacity, …) have no file on disk and must - // receive the shell so the router can take over. - app.get('*', serveStatic({ path: './apps/web/dist/index.html' })); + /* + * Client-side routes (/margin, /capacity, …) have no file on disk and must + * receive the shell so the router can take over. + * + * The `/api/` guard is repeated here deliberately. Without it an unknown API + * path — a typo, a renamed endpoint, an older client — falls through to this + * fallback and returns **HTTP 200 with the SPA's HTML**. That is close to the + * worst possible failure for an API consumer: `response.ok` is true, so + * nothing treats it as an error, and the caller then fails on `JSON.parse` + * with "Unexpected token '<'" a long way from the actual cause. The MCP + * server, the CLI and Piggy all consume this API and would all have hit it. + * + * Confirmed against production before fixing: an authenticated GET to + * /api/keys (the real path is /api/api-keys) returned 200 text/html. + */ + app.get('*', async (c, next) => { + if (new URL(c.req.url).pathname.startsWith('/api/')) return next(); + return serveStatic({ path: './apps/web/dist/index.html' })(c, next); + }); console.log('[pig] serving front end from', webDist); } From c821b2ca0783c15b58071ee1d8d113db4137ffed Mon Sep 17 00:00:00 2001 From: karti Date: Thu, 13 Aug 2026 02:32:28 -0700 Subject: [PATCH 3/5] Authenticate against any OIDC provider, for on-premises installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam existed with only a Supabase implementation, so an on-prem deployment had no way to authenticate. A customer running PIG inside their own network already has Okta, Entra, Keycloak, Auth0 or Google Workspace; asking them to stand up a second identity system is a serious adoption tax and in a regulated environment usually refused outright. Setting PIG_OIDC_ISSUER is normally the whole configuration — the JWKS is discovered from the issuer's well-known document. PIG_OIDC_JWKS_URI skips discovery entirely for an air-gapped network. OIDC takes precedence over Supabase so an on-prem install can leave the hosted values in its environment file without them quietly taking over. Three decisions worth stating: Discovery is resolved lazily and the FAILURE is not cached. Doing it per request would put the customer's identity provider on the critical path of every API call; doing it eagerly at boot would mean their IdP rebooting takes the CRM down with it. So it happens on first use and retries on the next request. The audience check is optional but warned about loudly. Without it, a token the provider issued for ANY other application in the same tenant verifies here — a token minted for an unrelated internal tool would be accepted as a PIG session. It cannot be mandatory because some providers legitimately issue single-audience tokens. Email falls back through email, preferred_username and upn, because providers disagree, but a preferred_username without an "@" is ignored — PIG keys membership on the address, and a bare username must never become an account identity. Also fixed a warning that claimed "authentication is DISABLED" on a correctly configured OIDC deployment. That is worse than silence: an operator who reads it on a secure install learns to ignore the warnings. The dev bypass itself was already correct — it keys on the resolved provider rather than on Supabase. 18 new tests, most of them about what the provider must REFUSE: a foreign signing key, a foreign issuer, a token for a different application, an expired token, a token with no subject, and a discovery outage that must not become permanent. Keys are generated per test and the JWKS is served locally, so they run offline. Verified: production refuses to start with neither provider, starts with OIDC alone, enforces 401 on an unauthenticated request, and warns only about the genuinely missing admin list. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 21 +++ AGENTS.md | 5 + apps/api/e2e/critical-path.test.ts | 1 + apps/api/src/lib/auth-provider.ts | 159 ++++++++++++++++- apps/api/src/lib/config.ts | 58 +++++- apps/api/test/auth-provider.test.ts | 265 ++++++++++++++++++++-------- deploy/README.md | 28 +++ 7 files changed, 453 insertions(+), 84 deletions(-) diff --git a/.env.example b/.env.example index 496e602..7cdf4bb 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,27 @@ SUPABASE_ANON_KEY= # PIG runs fine in invite-only mode. Treat it as the most powerful secret here. SUPABASE_SERVICE_KEY= +# --- Auth: on-premises (OIDC) --------------------------------------------- +# Set PIG_OIDC_ISSUER to authenticate against your own identity provider — +# Okta, Entra, Keycloak, Auth0, Authentik, Google Workspace, anything +# standards-compliant. It TAKES PRECEDENCE over the Supabase values above, so +# an on-prem install can leave those in place. +# +# PIG never sees a password. It verifies the token your provider issued and +# reads two things: a stable subject, and an email. Everything else — teams, +# roles, capabilities — is PIG's own data keyed on that subject, so users are +# provisioned in PIG by invite, not by your directory. +PIG_OIDC_ISSUER= +# Optional. Discovered from the issuer's /.well-known/openid-configuration when +# omitted. Set it to skip discovery entirely on an air-gapped network. +PIG_OIDC_JWKS_URI= +# STRONGLY recommended. Without it, a token your provider issued for ANY other +# application in the same tenant is accepted here as a PIG session. +PIG_OIDC_AUDIENCE= +# Comma-separated, in preference order. Defaults to email,preferred_username,upn +# which covers most providers; Entra sometimes needs upn first. +PIG_OIDC_EMAIL_CLAIMS= + # --- Application ------------------------------------------------------------ PIG_PORT=8920 PIG_PUBLIC_URL=http://localhost:8920 diff --git a/AGENTS.md b/AGENTS.md index 6d69968..314f909 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,11 @@ in the service layer or in the agent. The API signals the agent by *writing a row to `agent_tasks`*, never by calling it — so the queue survives the agent being down and no request thread ever blocks on a model. +**There are two auth providers, behind one interface.** Supabase for the +hosted deployment, OIDC for on-premises — see `apps/api/src/lib/auth-provider.ts`. +Both reduce to "verify a bearer token, return a subject and an email", because +that is all PIG needs. Never reach for a provider SDK outside that file. + **Authentication is not authorization.** A verified JWT proves someone has an account in an identity provider that PIG *shares with another application*. It does not prove they belong here. Access requires a row in PIG's own `users` diff --git a/apps/api/e2e/critical-path.test.ts b/apps/api/e2e/critical-path.test.ts index 5a6b50d..22edd07 100644 --- a/apps/api/e2e/critical-path.test.ts +++ b/apps/api/e2e/critical-path.test.ts @@ -53,6 +53,7 @@ test('invite-bound member creates, sells and observes capacity through authentic PIGGY_ENABLED: 'false', }); const authProvider = { + name: 'e2e-stub', async verifyAccessToken(token: string) { if (token !== accessToken) throw new Error('Invalid E2E token.'); return { subject, email }; diff --git a/apps/api/src/lib/auth-provider.ts b/apps/api/src/lib/auth-provider.ts index 7f1a22e..61cc9d2 100644 --- a/apps/api/src/lib/auth-provider.ts +++ b/apps/api/src/lib/auth-provider.ts @@ -4,8 +4,22 @@ * 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. + * + * Two implementations: + * + * **Supabase** — the hosted deployment. Well-known JWKS path, fixed issuer. + * **OIDC** — any standards-compliant identity provider, which is what an + * on-premises install needs. The customer already runs Okta, + * Entra, Keycloak, Auth0, Authentik or Google Workspace behind + * their VPN; asking them to stand up a second identity system + * to use PIG would be a serious adoption tax, and in a + * regulated environment often simply refused. + * + * Both reduce to the same thing — verify a bearer token, return a stable + * subject and an email — because that is all PIG needs. Everything downstream + * (teams, roles, capabilities) is PIG's own data keyed on that subject. */ -import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'; import type { Config } from './config'; export interface VerifiedIdentity { @@ -14,6 +28,8 @@ export interface VerifiedIdentity { } export interface AuthProvider { + /** Human-readable, for startup logging and the health surface. */ + readonly name: string; verifyAccessToken(token: string): Promise; } @@ -24,6 +40,7 @@ export function createSupabaseAuthProvider(supabaseUrl: string): AuthProvider { const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`)); return { + name: 'supabase', async verifyAccessToken(token: string): Promise { const { payload } = await jwtVerify(token, jwks, { issuer }); if (!payload.sub) throw new Error('token has no subject'); @@ -36,8 +53,146 @@ export function createSupabaseAuthProvider(supabaseUrl: string): AuthProvider { }; } +export interface OidcProviderOptions { + /** The `iss` value the provider stamps into its tokens. */ + issuer: string; + /** + * JWKS location. Optional: when omitted it is discovered from + * `${issuer}/.well-known/openid-configuration`, which every compliant + * provider serves. Setting it explicitly avoids one startup fetch and lets + * an air-gapped deployment skip discovery entirely. + */ + jwksUri?: string; + /** + * Expected audience. **Strongly recommended.** + * + * Without it, any token the identity provider issued for *any* application + * in the same tenant will verify here — a token minted for an unrelated + * internal tool would be accepted as a PIG session. `jose` only checks the + * audience when asked to, so leaving this unset is a real hole rather than a + * relaxed default, and it is warned about at boot. + */ + audience?: string; + /** + * Claim to read the email from. Providers disagree: most use `email`, some + * corporate Entra configurations use `preferred_username` or `upn`. Each + * candidate is tried in order. + */ + emailClaims?: string[]; + /** Tolerance for clock skew between PIG and the provider. */ + clockToleranceSeconds?: number; +} + +const DEFAULT_EMAIL_CLAIMS = ['email', 'preferred_username', 'upn']; + +export function createOidcAuthProvider(options: OidcProviderOptions): AuthProvider { + const issuer = options.issuer.replace(/\/+$/, ''); + const emailClaims = options.emailClaims?.length ? options.emailClaims : DEFAULT_EMAIL_CLAIMS; + + /* + * Resolved once, lazily, and cached — including the failure. + * + * Discovery is a network call, so doing it per request would put the + * identity provider on the critical path of every API call. Doing it eagerly + * at boot would mean PIG refuses to start if the provider is briefly + * unreachable, which on a customer's own network is a bad trade: their + * identity provider rebooting should not take the CRM down with it. + * + * So it happens on first use and is retried on the next request if it fails. + */ + let jwksPromise: Promise> | null = null; + + async function resolveJwks() { + if (options.jwksUri) return createRemoteJWKSet(new URL(options.jwksUri)); + + const discoveryUrl = `${issuer}/.well-known/openid-configuration`; + const response = await fetch(discoveryUrl, { headers: { accept: 'application/json' } }); + if (!response.ok) { + throw new Error( + `OIDC discovery failed: ${discoveryUrl} returned ${response.status}. ` + + 'Set PIG_OIDC_JWKS_URI to skip discovery.', + ); + } + + const document = (await response.json()) as { jwks_uri?: string; issuer?: string }; + if (!document.jwks_uri) { + throw new Error(`OIDC discovery document at ${discoveryUrl} has no jwks_uri.`); + } + + // A discovery document whose issuer disagrees with the configured one means + // the deployment is pointed somewhere unexpected. Verification would fail + // later anyway; failing here says why. + if (document.issuer && document.issuer.replace(/\/+$/, '') !== issuer) { + throw new Error( + `OIDC issuer mismatch: configured ${issuer}, discovery reports ${document.issuer}.`, + ); + } + + return createRemoteJWKSet(new URL(document.jwks_uri)); + } + + return { + name: 'oidc', + async verifyAccessToken(token: string): Promise { + if (!jwksPromise) { + jwksPromise = resolveJwks().catch((error) => { + // Clear the cache so the next request retries rather than being + // stuck with a rejected promise for the process lifetime. + jwksPromise = null; + throw error; + }); + } + + const jwks = await jwksPromise; + const { payload } = await jwtVerify(token, jwks, { + issuer, + ...(options.audience ? { audience: options.audience } : {}), + clockTolerance: options.clockToleranceSeconds ?? 5, + }); + + if (!payload.sub) throw new Error('token has no subject'); + + return { subject: payload.sub, email: readEmail(payload, emailClaims) }; + }, + }; +} + +function readEmail(payload: JWTPayload, claims: string[]): string | undefined { + for (const claim of claims) { + const value = payload[claim]; + // A `preferred_username` is not always an address; only take it if it + // looks like one, so a bare username never becomes an account identity. + if (typeof value === 'string' && value.includes('@')) return value.toLowerCase(); + } + return undefined; +} + +/** + * Build the provider this deployment is configured for. + * + * OIDC wins when both are set, so an on-premises install can keep the Supabase + * values in its environment file without them quietly taking precedence. + */ export function createConfiguredAuthProvider( - config: Pick, + config: Pick< + Config, + | 'SUPABASE_URL' + | 'PIG_OIDC_ISSUER' + | 'PIG_OIDC_JWKS_URI' + | 'PIG_OIDC_AUDIENCE' + | 'PIG_OIDC_EMAIL_CLAIMS' + >, ): AuthProvider | null { + if (config.PIG_OIDC_ISSUER) { + return createOidcAuthProvider({ + issuer: config.PIG_OIDC_ISSUER, + jwksUri: config.PIG_OIDC_JWKS_URI || undefined, + audience: config.PIG_OIDC_AUDIENCE || undefined, + emailClaims: config.PIG_OIDC_EMAIL_CLAIMS + ? config.PIG_OIDC_EMAIL_CLAIMS.split(',').map((claim) => claim.trim()).filter(Boolean) + : undefined, + }); + } + return config.SUPABASE_URL ? createSupabaseAuthProvider(config.SUPABASE_URL) : null; } diff --git a/apps/api/src/lib/config.ts b/apps/api/src/lib/config.ts index 8155f7f..b3168d4 100644 --- a/apps/api/src/lib/config.ts +++ b/apps/api/src/lib/config.ts @@ -33,6 +33,21 @@ const schema = z.object({ DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'), SUPABASE_URL: z.string().url().optional(), + + /* + * OIDC — the on-premises path. + * + * A customer running PIG inside their own network already has an identity + * provider. Setting PIG_OIDC_ISSUER switches authentication to it and takes + * precedence over any Supabase values left in the environment file. + */ + PIG_OIDC_ISSUER: z.string().url().optional(), + /** Optional. Discovered from the issuer when omitted. */ + PIG_OIDC_JWKS_URI: z.string().url().optional(), + /** Strongly recommended — see the boot warning. */ + PIG_OIDC_AUDIENCE: z.string().optional(), + /** Comma-separated, in preference order. Defaults cover most providers. */ + PIG_OIDC_EMAIL_CLAIMS: z.string().optional(), SUPABASE_ANON_KEY: z.string().optional(), SUPABASE_SERVICE_KEY: z.string().optional(), @@ -203,17 +218,48 @@ function warnOnFootguns(config: Config): void { warn('PIG_ADMIN_EMAILS is empty — no user will have platform-admin rights.'); } - if (!config.SUPABASE_URL) { + // Keyed on BOTH providers, not just Supabase. An OIDC deployment has + // authentication and this warning previously claimed it did not — which is + // worse than saying nothing, because an operator reading "authentication is + // DISABLED" on a correctly secured install learns to ignore the warnings. + if (!config.SUPABASE_URL && !config.PIG_OIDC_ISSUER) { warn( - 'SUPABASE_URL is not set — authentication is DISABLED and every request ' + - 'runs as the development user. Never do this in production.', + 'No identity provider is configured (SUPABASE_URL or PIG_OIDC_ISSUER) — ' + + 'authentication is DISABLED and every request runs as the development ' + + 'user. Never do this in production.', ); } - if (config.isProduction && !config.SUPABASE_URL) { + if (config.PIG_OIDC_ISSUER && config.SUPABASE_URL) { + warn( + 'Both PIG_OIDC_ISSUER and SUPABASE_URL are set — OIDC takes precedence and ' + + 'Supabase will not be used for authentication.', + ); + } + + if (config.PIG_OIDC_ISSUER && !config.PIG_OIDC_AUDIENCE) { + // Not fatal, because some providers issue single-audience tokens where it + // adds nothing — but on a shared corporate tenant this is the difference + // between "a token for PIG" and "a token for anything in the company". + warn( + 'PIG_OIDC_AUDIENCE is not set. Any token your identity provider issued for ' + + 'ANY application in the same tenant will be accepted here. Set it unless ' + + 'you are certain that is safe.', + ); + } + + if (config.PIG_OIDC_ISSUER && config.SUPABASE_SERVICE_KEY) { + warn( + 'SUPABASE_SERVICE_KEY is set while running on OIDC. Self-registration mints ' + + 'Supabase accounts, which an OIDC deployment does not use — unset it and ' + + 'provision users through your identity provider instead.', + ); + } + + if (config.isProduction && !config.SUPABASE_URL && !config.PIG_OIDC_ISSUER) { throw new Error( - 'Refusing to start: NODE_ENV=production with no SUPABASE_URL would serve ' + - 'the entire CRM unauthenticated.', + 'Refusing to start: NODE_ENV=production with neither SUPABASE_URL nor ' + + 'PIG_OIDC_ISSUER would serve the entire CRM unauthenticated.', ); } diff --git a/apps/api/test/auth-provider.test.ts b/apps/api/test/auth-provider.test.ts index d7c4d98..bdc9d21 100644 --- a/apps/api/test/auth-provider.test.ts +++ b/apps/api/test/auth-provider.test.ts @@ -1,101 +1,214 @@ /** - * Tests for the identity-provider boundary. + * Tests for the OIDC authentication provider. * - * These cases pin the trust decisions shared by protected requests and profile - * creation. Membership remains deliberately outside this module. + * This is the code that decides whether a stranger is who they claim to be, so + * the cases below are mostly about what it must **refuse**. A provider that + * accepts a token it should not is not a bug you find in staging. + * + * Keys are generated per test and the JWKS is served from a local HTTP server, + * so these run offline and deterministically — no network, no fixtures that + * expire. */ import { strict as assert } from 'node:assert'; -import { generateKeyPairSync, type KeyObject } from 'node:crypto'; +import { after, before, describe, it } from 'node:test'; 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'; +import { SignJWT, exportJWK, generateKeyPair, type JWK } from 'jose'; +import { createOidcAuthProvider, createConfiguredAuthProvider } from '../src/lib/auth-provider'; -describe('Supabase auth provider', () => { - let server: Server; - let provider: AuthProvider; - let issuer: string; - let privateKey: KeyObject; +let server: Server; +let origin: string; +// Derived from jose rather than referencing the DOM `CryptoKey` type, which +// is not in this package's type lib. +type SigningKey = Awaited>['privateKey']; - before(async () => { - const keys = generateKeyPairSync('rsa', { modulusLength: 2048 }); - privateKey = keys.privateKey; - const publicJwk = await exportJWK(keys.publicKey); +let privateKey: SigningKey; +let otherPrivateKey: SigningKey; +let jwks: { keys: JWK[] }; +/** Flipped per test to exercise discovery failures. */ +let discoveryStatus = 200; +let serveDiscovery = true; - server = createServer((request, response) => { - if (request.url !== '/auth/v1/.well-known/jwks.json') { - response.writeHead(404).end(); +before(async () => { + const pair = await generateKeyPair('RS256'); + const other = await generateKeyPair('RS256'); + privateKey = pair.privateKey; + otherPrivateKey = other.privateKey; + + const publicJwk = await exportJWK(pair.publicKey); + publicJwk.kid = 'test-key'; + publicJwk.alg = 'RS256'; + jwks = { keys: [publicJwk] }; + + server = createServer((req, res) => { + if (req.url === '/.well-known/openid-configuration') { + if (!serveDiscovery || discoveryStatus !== 200) { + res.writeHead(discoveryStatus === 200 ? 404 : discoveryStatus); + res.end('{}'); return; } - response.setHeader('content-type', 'application/json'); - response.end( - JSON.stringify({ - keys: [{ ...publicJwk, alg: 'RS256', kid: 'test-key', use: 'sig' }], - }), - ); - }); - - await new Promise((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); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ issuer: origin, jwks_uri: `${origin}/jwks` })); + return; + } + if (req.url === '/jwks') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify(jwks)); + return; + } + res.writeHead(404); + res.end(); }); - after( - () => - new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }), - ); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); - async function sign(claims: Record, tokenIssuer = issuer): Promise { - return new SignJWT(claims) - .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) - .setIssuer(tokenIssuer) - .setExpirationTime('5m') - .sign(privateKey); - } +after(() => server?.close()); - it('returns only the verified external identity claims', async () => { - const token = await sign({ sub: 'provider-user-1', email: 'Owner@Example.com' }); +async function mint( + claims: Record, + opts: { key?: SigningKey; issuer?: string; expiresIn?: string } = {}, +) { + return new SignJWT(claims) + .setProtectedHeader({ alg: 'RS256', kid: 'test-key' }) + .setIssuedAt() + .setIssuer(opts.issuer ?? origin) + .setExpirationTime(opts.expiresIn ?? '5m') + .sign(opts.key ?? privateKey); +} - assert.deepEqual(await provider.verifyAccessToken(token), { - subject: 'provider-user-1', - email: 'Owner@Example.com', - }); +describe('OIDC provider — what it accepts', () => { + it('verifies a well-formed token and returns subject and email', async () => { + const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' }); + const token = await mint({ sub: 'user-1', email: 'Person@Example.com', aud: 'pig' }); + const identity = await provider.verifyAccessToken(token); + + assert.equal(identity.subject, 'user-1'); + // Lower-cased, because PIG keys membership on the address and + // Person@ and person@ are the same person. + assert.equal(identity.email, 'person@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('discovers the JWKS from the issuer when no explicit URI is given', async () => { + const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' }); + const token = await mint({ sub: 'user-2', email: 'a@b.com', aud: 'pig' }); + assert.equal((await provider.verifyAccessToken(token)).subject, 'user-2'); }); - 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('skips discovery entirely when the JWKS URI is configured', async () => { + // The air-gapped path: no discovery request is made at all. + serveDiscovery = false; + try { + const provider = createOidcAuthProvider({ + issuer: origin, + jwksUri: `${origin}/jwks`, + audience: 'pig', + }); + const token = await mint({ sub: 'user-3', email: 'a@b.com', aud: 'pig' }); + assert.equal((await provider.verifyAccessToken(token)).subject, 'user-3'); + } finally { + serveDiscovery = true; + } }); - it('rejects a token with no stable subject', async () => { - const token = await sign({ email: 'owner@example.com' }); + it('falls back through email claims for providers that do not send `email`', async () => { + const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' }); + const token = await mint({ sub: 'user-4', preferred_username: 'someone@corp.com', aud: 'pig' }); + assert.equal((await provider.verifyAccessToken(token)).email, 'someone@corp.com'); + }); - await assert.rejects(provider.verifyAccessToken(token)); + it('ignores a preferred_username that is not an address', async () => { + // A bare username must never become an account identity — PIG keys + // membership on the email, and "jsmith" is not one. + const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' }); + const token = await mint({ sub: 'user-5', preferred_username: 'jsmith', aud: 'pig' }); + assert.equal((await provider.verifyAccessToken(token)).email, undefined); + }); +}); + +describe('OIDC provider — what it must refuse', () => { + const provider = () => createOidcAuthProvider({ issuer: origin, audience: 'pig' }); + + it('rejects a token signed by a different key', async () => { + const token = await mint({ sub: 'x', aud: 'pig' }, { key: otherPrivateKey }); + await assert.rejects(() => provider().verifyAccessToken(token)); + }); + + it('rejects a token from a different issuer', async () => { + const token = await mint({ sub: 'x', aud: 'pig' }, { issuer: 'https://evil.example' }); + await assert.rejects(() => provider().verifyAccessToken(token)); + }); + + it('rejects a token minted for a DIFFERENT application in the same tenant', async () => { + // The case the audience check exists for. Without it, a token issued for + // any other internal tool would be accepted as a PIG session. + const token = await mint({ sub: 'x', aud: 'some-other-app' }); + await assert.rejects(() => provider().verifyAccessToken(token)); + }); + + it('rejects an expired token', async () => { + const token = await mint({ sub: 'x', aud: 'pig' }, { expiresIn: '-1m' }); + await assert.rejects(() => provider().verifyAccessToken(token)); + }); + + it('rejects a token with no subject', async () => { + const token = await mint({ aud: 'pig' }); + await assert.rejects(() => provider().verifyAccessToken(token)); + }); + + it('rejects garbage', async () => { + await assert.rejects(() => provider().verifyAccessToken('not-a-token')); + }); + + it('surfaces a discovery failure and retries on the next call', async () => { + const p = createOidcAuthProvider({ issuer: origin, audience: 'pig' }); + const token = await mint({ sub: 'user-6', aud: 'pig' }); + + discoveryStatus = 503; + await assert.rejects(() => p.verifyAccessToken(token)); + + // The failure must not be cached for the process lifetime: an identity + // provider that reboots should not permanently break PIG. + discoveryStatus = 200; + assert.equal((await p.verifyAccessToken(token)).subject, 'user-6'); + }); +}); + +describe('createConfiguredAuthProvider', () => { + it('prefers OIDC when both are configured', () => { + const provider = createConfiguredAuthProvider({ + SUPABASE_URL: 'https://project.supabase.co', + PIG_OIDC_ISSUER: 'https://id.customer.internal', + PIG_OIDC_JWKS_URI: undefined, + PIG_OIDC_AUDIENCE: 'pig', + PIG_OIDC_EMAIL_CLAIMS: undefined, + }); + // So an on-prem install can leave the hosted values in place without them + // silently taking over. + assert.equal(provider?.name, 'oidc'); + }); + + it('uses Supabase when only it is configured', () => { + const provider = createConfiguredAuthProvider({ + SUPABASE_URL: 'https://project.supabase.co', + PIG_OIDC_ISSUER: undefined, + PIG_OIDC_JWKS_URI: undefined, + PIG_OIDC_AUDIENCE: undefined, + PIG_OIDC_EMAIL_CLAIMS: undefined, + }); + assert.equal(provider?.name, 'supabase'); + }); + + it('returns null when neither is configured', () => { + const provider = createConfiguredAuthProvider({ + SUPABASE_URL: undefined, + PIG_OIDC_ISSUER: undefined, + PIG_OIDC_JWKS_URI: undefined, + PIG_OIDC_AUDIENCE: undefined, + PIG_OIDC_EMAIL_CLAIMS: undefined, + }); + // The production guard in config.ts turns this into a refusal to start. + assert.equal(provider, null); }); }); diff --git a/deploy/README.md b/deploy/README.md index e5cf3a5..aead6e7 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -98,6 +98,34 @@ applied. Take a dump before a major upgrade anyway: docker compose -p pig exec db pg_dump -U pig pig | gzip > pig-$(date +%F).sql.gz ``` +## On-premises: using your own identity provider + +PIG authenticates against any standards-compliant OIDC provider, which is how +an install inside your own network works. Set: + +```bash +PIG_OIDC_ISSUER=https://id.yourcompany.internal +PIG_OIDC_AUDIENCE=pig # the client/app id you registered for PIG +``` + +That is usually the whole configuration — the JWKS is discovered from the +issuer. On an air-gapped network, set `PIG_OIDC_JWKS_URI` too and no discovery +request is made. + +`PIG_OIDC_ISSUER` takes precedence over `SUPABASE_URL`, so the hosted values +can stay in the environment file without quietly taking over. + +**Set the audience.** Without it, any token your provider issued for any +application in the same tenant verifies here — a token minted for an unrelated +internal tool would be accepted as a PIG session. PIG warns about this at boot +but cannot refuse, because some providers legitimately issue single-audience +tokens. + +**Provisioning stays in PIG.** Authenticating proves who someone is; it does +not make them a member. They still need an invite, and their team and role live +in PIG's database. That is deliberate — your directory should not have to model +"supply lead versus demand member" for one application. + ## A note on the auth project PIG verifies JWTs but authorizes from its own `users` table. If the Supabase From 2763531ce4d2b15a47ad447d6acc3177f7d1f1a2 Mon Sep 17 00:00:00 2001 From: karti Date: Thu, 13 Aug 2026 03:17:49 -0700 Subject: [PATCH 4/5] Align shadcn's `accent` token with what shadcn means by it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shadcn uses `bg-accent` for its SUBTLE surfaces — dropdown item hover, command row selection, ghost and outline button hover, the dialog close affordance. The brand colour in shadcn is `primary`. PIG's Tailwind config mapped `accent` to `--accent`, which is the brand. That inverted the meaning, so every shadcn hover and selection state painted a full-strength brand block. With the monochrome "pig" palette in dark mode the brand is near-white, so a selected command row rendered as a white slab against a near-black sheet. Measured before the change: selected row rgb(250,250,250) on a rgb(9,9,11) body. `accent` now aliases `--accent-subtle` and `accent-foreground` aliases `--accent-fg`, which is what those tokens were created for. The eleven places where PIG's own components wanted a solid brand fill — filled chips, selected card borders, progress bars — move to `primary`, which still resolves to `--accent`. A `brand` alias is added for clarity. After: selected row rgb(39,39,42) in dark and rgb(244,244,245) in light, both a subtle tint above the body; the pipeline's active stage chip stays a solid rgb(250,250,250) fill, unchanged. Found by opening overlays, which earlier screenshot sweeps never did — every route had been checked, but a dropdown or a command palette only misbehaves once it is open. Worth remembering: page-level sweeps do not exercise portals. Typecheck clean, 135 unit tests and e2e green, CSP hash unchanged, 0px horizontal overflow across 12 routes at 393px and 1440px. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 8 ++++++++ apps/web/src/components/AdminSettings.tsx | 2 +- apps/web/src/components/AllocationSheet.tsx | 4 ++-- apps/web/src/components/GoogleSheetsSource.tsx | 2 +- apps/web/src/components/PiggyChat.tsx | 2 +- apps/web/src/pages/Capacity.tsx | 4 ++-- apps/web/src/pages/CreateProfile.tsx | 2 +- apps/web/src/pages/FactReview.tsx | 2 +- apps/web/src/pages/Imports.tsx | 2 +- apps/web/src/pages/Pipeline.tsx | 2 +- apps/web/src/pages/Register.tsx | 2 +- apps/web/src/pages/Settings.tsx | 2 +- apps/web/tailwind.config.js | 18 ++++++++++++++++-- 13 files changed, 37 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 314f909..feb4d5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,14 @@ conflict on, do an existence check instead. flag set to false was silently on. Use the `envBoolean` helper in `apps/api/src/lib/config.ts`. +**shadcn's `accent` is a SUBTLE surface, not the brand.** shadcn uses +`bg-accent` for hover, focus and selected states — dropdown items, command +rows, ghost buttons. The brand is `primary`. In `tailwind.config.js`, `accent` +is therefore aliased to `--accent-subtle` and `primary` to `--accent`. Use +`bg-primary` for a solid brand fill; never `bg-accent`. Mapping them the other +way makes every hover state paint a full-strength brand block, which in dark +mode with the monochrome palette is a glaring white slab. + **Grid and flex children need `min-w-0`.** They default to `min-width: auto`, meaning they refuse to shrink below their content — and a `tabular-nums` figure, a `whitespace-nowrap` badge or a `truncate` title is all diff --git a/apps/web/src/components/AdminSettings.tsx b/apps/web/src/components/AdminSettings.tsx index 71103a1..4ef6b34 100644 --- a/apps/web/src/components/AdminSettings.tsx +++ b/apps/web/src/components/AdminSettings.tsx @@ -67,7 +67,7 @@ export function AdminSettings() {
-
+
diff --git a/apps/web/src/components/AllocationSheet.tsx b/apps/web/src/components/AllocationSheet.tsx index 2e16205..c65f476 100644 --- a/apps/web/src/components/AllocationSheet.tsx +++ b/apps/web/src/components/AllocationSheet.tsx @@ -526,8 +526,8 @@ function CommitmentContext({ row, detail, match, quotedPrice }: { row: Availabil {match ? 0.7 ? 'positive' : 'neutral'}>{percent(match.score)} fit : null}
-
-
+
+

Sold

{compactNumber(row.soldGpuHours)} hrs

diff --git a/apps/web/src/components/GoogleSheetsSource.tsx b/apps/web/src/components/GoogleSheetsSource.tsx index 104d4d8..1aa91cd 100644 --- a/apps/web/src/components/GoogleSheetsSource.tsx +++ b/apps/web/src/components/GoogleSheetsSource.tsx @@ -137,7 +137,7 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT {files.isLoading ? : files.isError ? : files.data?.files.length === 0 ? : (
{files.data?.files.map((file) => ( - diff --git a/apps/web/src/components/PiggyChat.tsx b/apps/web/src/components/PiggyChat.tsx index 2efd4a5..8afae66 100644 --- a/apps/web/src/components/PiggyChat.tsx +++ b/apps/web/src/components/PiggyChat.tsx @@ -264,7 +264,7 @@ function PiggyChatPanel({ function ChatMessage({ message }: { message: TranscriptMessage }) { if (message.role === 'user') { - return

{message.content}

; + return

{message.content}

; } return (
diff --git a/apps/web/src/pages/Capacity.tsx b/apps/web/src/pages/Capacity.tsx index 26ff986..05a545f 100644 --- a/apps/web/src/pages/Capacity.tsx +++ b/apps/web/src/pages/Capacity.tsx @@ -161,9 +161,9 @@ function CapacityCard({ row, writable, onAllocate }: { row: AvailabilityRow; wri bar made mostly of unconverted holds is a lie a seller would act on. */}
-
+
diff --git a/apps/web/src/pages/CreateProfile.tsx b/apps/web/src/pages/CreateProfile.tsx index 758cb47..d3ced5e 100644 --- a/apps/web/src/pages/CreateProfile.tsx +++ b/apps/web/src/pages/CreateProfile.tsx @@ -95,7 +95,7 @@ export function CreateProfile({ className={[ 'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors', team === value - ? 'border-accent bg-accent-subtle' + ? 'border-primary bg-accent-subtle' : 'border-border hover:bg-surface-2', ].join(' ')} > diff --git a/apps/web/src/pages/FactReview.tsx b/apps/web/src/pages/FactReview.tsx index f5c9a83..9b39b5c 100644 --- a/apps/web/src/pages/FactReview.tsx +++ b/apps/web/src/pages/FactReview.tsx @@ -99,7 +99,7 @@ export function FactReview() { - +
diff --git a/apps/web/src/pages/Imports.tsx b/apps/web/src/pages/Imports.tsx index e77cc97..5a6af92 100644 --- a/apps/web/src/pages/Imports.tsx +++ b/apps/web/src/pages/Imports.tsx @@ -145,7 +145,7 @@ export function Imports() { type="button" aria-pressed={entity === candidate} onClick={() => resetForEntity(candidate)} - className={entity === candidate ? 'tap card min-w-0 border-accent p-4 text-left ring-1 ring-accent' : 'tap card min-w-0 p-4 text-left'} + className={entity === candidate ? 'tap card min-w-0 border-primary p-4 text-left ring-1 ring-primary' : 'tap card min-w-0 p-4 text-left'} >

{candidateDefinition.label}

{candidateDefinition.description}

diff --git a/apps/web/src/pages/Pipeline.tsx b/apps/web/src/pages/Pipeline.tsx index 39a0acb..e5b83da 100644 --- a/apps/web/src/pages/Pipeline.tsx +++ b/apps/web/src/pages/Pipeline.tsx @@ -184,7 +184,7 @@ function PipelineBoard diff --git a/apps/web/src/pages/Register.tsx b/apps/web/src/pages/Register.tsx index 2a35b37..4583e50 100644 --- a/apps/web/src/pages/Register.tsx +++ b/apps/web/src/pages/Register.tsx @@ -172,7 +172,7 @@ export function Register({ className={[ 'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors', team === value - ? 'border-accent bg-accent-subtle' + ? 'border-primary bg-accent-subtle' : 'border-border hover:bg-surface-2', ].join(' ')} > diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index 9d34d09..ada565c 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -93,7 +93,7 @@ function Appearance() { title={option.label} className={[ 'tap relative flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors', - selected ? 'border-accent bg-accent-subtle' : 'border-border hover:bg-surface-2', + selected ? 'border-primary bg-accent-subtle' : 'border-border hover:bg-surface-2', ].join(' ')} > {/* diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js index 61a0260..433e17c 100644 --- a/apps/web/tailwind.config.js +++ b/apps/web/tailwind.config.js @@ -20,8 +20,22 @@ export default { foreground: 'hsl(var(--fg))', muted: 'hsl(var(--muted))', 'muted-foreground': 'hsl(var(--muted))', - accent: 'hsl(var(--accent))', - 'accent-foreground': 'hsl(var(--accent-on))', + /* + * shadcn's `accent` is its SUBTLE hover/selected surface — dropdown + * items, command rows, ghost-button hover. It is not the brand colour; + * that is `primary`, mapped below. + * + * Mapping `accent` to --accent (the brand) inverted this, so every + * hover state painted a full-strength brand block: in dark mode with + * the monochrome "pig" accent that is near-white, which made a selected + * command row glare. Aliased to the subtle pair instead, so shadcn + * primitives tint the way their authors intended while PIG's own brand + * fills use `primary`. + */ + accent: 'hsl(var(--accent-subtle))', + 'accent-foreground': 'hsl(var(--accent-fg))', + /** The brand itself, for PIG's own components that need a solid fill. */ + brand: 'hsl(var(--accent))', 'accent-fg': 'hsl(var(--accent-fg))', 'accent-on': 'hsl(var(--accent-on))', 'accent-subtle': 'hsl(var(--accent-subtle))', From c2c7fb9c19ac968b44c3a5351d8bce33b2a5f923 Mon Sep 17 00:00:00 2001 From: karti Date: Thu, 13 Aug 2026 03:39:00 -0700 Subject: [PATCH 5/5] Make mutations confirm themselves, and seed the evidence trail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two demo gaps, both of which made working features look like they were not there. **Toasts fired into nothing.** RecordSheets already called toast.success on every save, but was never mounted, so nothing appeared. It could not be mounted, either: the shadcn original imports next-themes, which PIG does not use — it has its own provider so a chosen theme is persisted server-side and follows a user between devices. Rewired to PIG's useTheme, mounted inside ThemeProvider, and offset clear of the phone tab bar and the home indicator. Feedback added where the interface otherwise gives none: allocation and hold report the GPU-hours actually written, because the sheet closes on success and the only other evidence is a number moving off-screen; releasing a hold says the capacity is sellable again; fact decisions say what the decision meant, and that approving evidence is not the same as writing it to a record; the profile form confirms rather than just clearing itself, which otherwise reads as the input being discarded. **The fact table was empty**, so the review queue and every provenance tooltip had nothing to show — the mechanism that makes an agent-written CRM trustworthy, invisible. Six agent-derived facts seeded with a deliberate mix: two applied, showing what a confident agent writes unprompted, and four proposed, including one weak claim that a reviewer should reject, so the queue is not a row of obvious approvals. Each carries a score, a band, evidence and where available a source. Idempotent on subject+field+value; verified over two runs. Verified: toast confirmed firing in a real browser on a 393px viewport, 135 unit tests and e2e green, typecheck clean, CSP hash unchanged, 0px horizontal overflow across 12 routes at both breakpoints. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 7 + apps/web/src/App.tsx | 3 + apps/web/src/components/AllocationSheet.tsx | 29 +++- apps/web/src/components/ui/sonner.tsx | 46 ++++-- apps/web/src/pages/FactReview.tsx | 12 +- apps/web/src/pages/Settings.tsx | 5 + packages/db/src/seed/demo.ts | 160 ++++++++++++++++++++ 7 files changed, 242 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index feb4d5c..0274624 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,13 @@ conflict on, do an existence check instead. flag set to false was silently on. Use the `envBoolean` helper in `apps/api/src/lib/config.ts`. +**Mutations must confirm themselves.** `` is mounted in `App.tsx` +inside `ThemeProvider`; use `toast.success` / `toast.error` in every mutation's +`onSuccess` / `onError`. The shadcn Toaster ships wired to `next-themes`, which +PIG does not use — it was rewired to PIG's `useTheme`. Before that it was never +mounted, so toasts already written in RecordSheets fired into nothing and every +save completed in silence. + **shadcn's `accent` is a SUBTLE surface, not the brand.** shadcn uses `bg-accent` for hover, focus and selected states — dropdown items, command rows, ghost buttons. The brand is `primary`. In `tailwind.config.js`, `accent` diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 4974c1c..dc2f05e 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -12,6 +12,7 @@ import { CreateProfile } from '@/pages/CreateProfile'; import { Register } from '@/pages/Register'; import { PiggyMark } from '@/components/PiggyMark'; import { EmptyState } from '@/components/ui'; +import { Toaster } from '@/components/ui/sonner'; import { usePageTitle } from '@/lib/title'; const Overview = lazy(() => import('@/pages/Overview').then(({ Overview }) => ({ default: Overview }))); @@ -79,6 +80,8 @@ export function App() { + {/* Inside ThemeProvider: the host reads the resolved light/dark value. */} + ); diff --git a/apps/web/src/components/AllocationSheet.tsx b/apps/web/src/components/AllocationSheet.tsx index c65f476..c811951 100644 --- a/apps/web/src/components/AllocationSheet.tsx +++ b/apps/web/src/components/AllocationSheet.tsx @@ -37,6 +37,7 @@ import { } from '@/components/ui/sheet'; import { Textarea } from '@/components/ui/textarea'; import { ApiError, compactNumber, get, money, percent, post, shortDate } from '@/lib/api'; +import { toast } from 'sonner'; export interface AvailabilityRow { commitmentId: string; @@ -295,10 +296,24 @@ export function AllocationSheet({ }) : post('/api/allocations', { ...body, status: values.status }); }, - onSuccess: async () => { + onSuccess: async (allocation, values) => { await refresh(); onOpenChange(false); + // The sheet closes on success, so without this the only evidence the + // write landed is a number moving somewhere off-screen. Report what + // actually happened, in the units the seller was thinking in. + const hours = Number(allocation.gpuHours ?? values.gpuHours).toLocaleString(); + toast.success( + values.kind === 'hold' ? 'Capacity held' : 'Capacity allocated', + { + description: + values.kind === 'hold' + ? `${hours} GPU-hours reserved. The hold releases automatically when it expires.` + : `${hours} GPU-hours committed. Margin and utilisation have been updated.`, + }, + ); }, + onError: (error) => toast.error('Could not save', { description: errorMessage(error) }), }); const release = useMutation({ mutationFn: (id: string) => @@ -306,8 +321,16 @@ export function AllocationSheet({ reason: releaseReason.trim() || undefined, }), onMutate: () => setReleaseError(null), - onSuccess: refresh, - onError: (error) => setReleaseError(errorMessage(error)), + onSuccess: async () => { + await refresh(); + toast.success('Hold released', { + description: 'The capacity is available to sell again.', + }); + }, + onError: (error) => { + setReleaseError(errorMessage(error)); + toast.error('Could not release the hold', { description: errorMessage(error) }); + }, }); const chooseCommitment = (id: string) => { diff --git a/apps/web/src/components/ui/sonner.tsx b/apps/web/src/components/ui/sonner.tsx index 1128edf..b6bdf41 100644 --- a/apps/web/src/components/ui/sonner.tsx +++ b/apps/web/src/components/ui/sonner.tsx @@ -1,29 +1,43 @@ -import { useTheme } from "next-themes" -import { Toaster as Sonner } from "sonner" +/** + * Toast host. + * + * The shadcn original reads the theme from `next-themes`, which PIG does not + * use — it has its own provider so a user's choice can be persisted server-side + * and follow them between devices. Importing next-themes here would have thrown + * at module load, which is why the Toaster was never mounted and every mutation + * in the app completed in silence. + * + * Rewired to PIG's `useTheme`, which already resolves `system` to a concrete + * light or dark value. + */ +import { Toaster as Sonner, type ToasterProps } from 'sonner'; +import { useTheme } from '@/lib/theme'; -type ToasterProps = React.ComponentProps - -const Toaster = ({ ...props }: ToasterProps) => { - const { theme = "system" } = useTheme() +export function Toaster(props: ToasterProps) { + const { resolved } = useTheme(); return ( - ) + ); } - -export { Toaster } diff --git a/apps/web/src/pages/FactReview.tsx b/apps/web/src/pages/FactReview.tsx index 9b39b5c..b1a33d4 100644 --- a/apps/web/src/pages/FactReview.tsx +++ b/apps/web/src/pages/FactReview.tsx @@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button'; import { get, patch, relativeTime } from '@/lib/api'; import { can } from '@/lib/permissions'; import { usePageTitle } from '@/lib/title'; +import { toast } from 'sonner'; interface ReviewItem { fact: SourcedFact; @@ -74,7 +75,16 @@ export function FactReview() { const decision = useMutation({ mutationFn: ({ id, status }: { id: string; status: 'approved' | 'dismissed' }) => patch(`/api/facts/${id}/decision`, { status }), - onSuccess: async () => { + onSuccess: async (_result, variables) => { + toast.success( + variables.status === 'approved' ? 'Evidence accepted' : 'Claim dismissed', + { + description: + variables.status === 'approved' + ? 'Recorded as reviewed. Applying it to the record is a separate, field-aware step.' + : 'It will not be proposed again from the same evidence.', + }, + ); await queryClient.invalidateQueries({ queryKey: ['facts', 'proposed'] }); }, }); diff --git a/apps/web/src/pages/Settings.tsx b/apps/web/src/pages/Settings.tsx index ada565c..8ca9113 100644 --- a/apps/web/src/pages/Settings.tsx +++ b/apps/web/src/pages/Settings.tsx @@ -14,6 +14,7 @@ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from ' import { useState } from 'react'; import { usePageTitle } from '@/lib/title'; import { AdminSettings } from '@/components/AdminSettings'; +import { toast } from 'sonner'; interface Me { id: string; @@ -144,7 +145,11 @@ function Profile({ me }: { me: Me | undefined }) { void queryClient.invalidateQueries({ queryKey: ['me'] }); setName(''); setTitle(''); + // The form clears itself on success, which without confirmation reads as + // though the input was discarded rather than saved. + toast.success('Profile saved'); }, + onError: () => toast.error('Could not save your profile'), }); if (!me) return null; diff --git a/packages/db/src/seed/demo.ts b/packages/db/src/seed/demo.ts index cb701cc..adc3d97 100644 --- a/packages/db/src/seed/demo.ts +++ b/packages/db/src/seed/demo.ts @@ -41,6 +41,7 @@ import { createDatabase } from '../client'; import { accounts, activities, + facts, allocations, capacityCommitments, capacityRequests, @@ -660,7 +661,166 @@ async function seedDemo() { } } + // -------------------------------------------------- agent-derived facts + // + // Without these the fact-review queue and every provenance tooltip are + // empty, which hides the thing that makes an agent-written CRM trustworthy: + // that each claim carries a score, a band, evidence and a source, and that + // only verified claims apply themselves. + // + // The mix is deliberate. Two `applied` facts show what a confident agent + // writes unprompted; four `proposed` show what waits for a human; one is a + // near-miss that a reviewer should reject, so the queue is not a row of + // obvious approvals. + const factSeeds: { + accountDomain?: string; + contactName?: string; + field: string; + value: string; + score: string; + band: 'verified' | 'probable' | 'possible'; + status: 'applied' | 'proposed'; + method: string; + sourceUrl?: string; + evidence: Record; + }[] = [ + { + accountDomain: 'coreweave.com', + field: 'supplierType', + value: 'neocloud', + score: '0.960', + band: 'verified', + status: 'applied', + method: 'web_search', + sourceUrl: 'https://www.coreweave.com/', + evidence: { + quote: 'Describes itself as an AI hyperscaler providing GPU cloud infrastructure.', + corroboration: 2, + }, + }, + { + accountDomain: 'nebius.com', + field: 'jurisdiction', + value: 'European Union', + score: '0.910', + band: 'verified', + status: 'applied', + method: 'web_search', + sourceUrl: 'https://nebius.com/', + evidence: { + quote: 'Operates a datacentre in Finland, inside the EU data-residency perimeter.', + matters: 'Determines eligibility for customers with EU residency requirements.', + }, + }, + { + accountDomain: 'crusoe.ai', + field: 'certifications', + value: 'SOC 2 Type II', + score: '0.720', + band: 'probable', + status: 'proposed', + method: 'web_search', + sourceUrl: 'https://crusoe.ai/', + evidence: { + quote: 'A trust page references SOC 2, but the report scope and observation window are not stated.', + caution: 'Scope matters — a report can cover only some products.', + }, + }, + { + accountDomain: 'lambda.ai', + field: 'supplierType', + value: 'neocloud', + score: '0.680', + band: 'probable', + status: 'proposed', + method: 'web_search', + sourceUrl: 'https://lambda.ai/', + evidence: { quote: 'Markets GPU cloud and on-premises clusters.' }, + }, + { + contactName: 'Dana Whitfield', + field: 'title', + value: 'VP Infrastructure', + score: '0.540', + band: 'possible', + status: 'proposed', + method: 'inference', + evidence: { + reasoning: 'A conference bio lists a VP title; the CRM records Head of Infrastructure.', + conflict: 'Sources disagree, and neither is dated.', + }, + }, + { + accountDomain: 'runpod.io', + field: 'customerSegment', + value: 'frontier_lab', + score: '0.310', + band: 'possible', + status: 'proposed', + method: 'inference', + evidence: { + reasoning: 'Inferred from a blog post mentioning large training runs.', + warning: + 'Weak. This is a supply-side provider, not a frontier lab — a reviewer should reject it.', + }, + }, + ]; + + let factsAdded = 0; + for (const seed of factSeeds) { + let accountId: string | undefined; + let contactId: string | undefined; + + if (seed.accountDomain) { + const [row] = await db + .select({ id: accounts.id }) + .from(accounts) + .where(eq(accounts.domain, seed.accountDomain)) + .limit(1); + accountId = row?.id; + } + if (seed.contactName) { + const [row] = await db + .select({ id: contacts.id }) + .from(contacts) + .where(eq(contacts.fullName, seed.contactName)) + .limit(1); + contactId = row?.id; + } + if (!accountId && !contactId) continue; + + // Idempotent on the natural key: one claim per subject per field per value. + const [existing] = await db + .select({ id: facts.id }) + .from(facts) + .where( + and( + accountId ? eq(facts.accountId, accountId) : eq(facts.contactId, contactId!), + eq(facts.field, seed.field), + eq(facts.value, seed.value), + ), + ) + .limit(1); + if (existing) continue; + + await db.insert(facts).values({ + accountId, + contactId, + field: seed.field, + value: seed.value, + score: seed.score, + band: seed.band, + status: seed.status, + method: seed.method, + sourceUrl: seed.sourceUrl, + evidence: seed.evidence, + observedAt: at(-Math.round(Math.random() * 6) - 1), + }); + factsAdded += 1; + } + console.log(' 4 capacity commitments, with sites, MSAs and negotiated SLAs'); + 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');