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 0430104..0274624 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` @@ -135,9 +140,34 @@ 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. +**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` +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 +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 @@ -155,6 +185,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/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/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); } 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/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/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..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) => { @@ -526,8 +549,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/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) { 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/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..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'] }); }, }); @@ -99,7 +109,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..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; @@ -93,7 +94,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(' ')} > {/* @@ -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/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))', 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 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');