Merge remote-tracking branch 'gitea/main' into feat/revenue-intelligence
CI / verify (push) Successful in 2m54s

This commit is contained in:
2026-08-13 03:50:26 -07:00
24 changed files with 786 additions and 126 deletions
+21
View File
@@ -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
+40 -3
View File
@@ -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.** `<Toaster />` 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.
+1
View File
@@ -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 };
+157 -2
View File
@@ -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<VerifiedIdentity>;
}
@@ -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<VerifiedIdentity> {
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<ReturnType<typeof createRemoteJWKSet>> | 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<VerifiedIdentity> {
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, 'SUPABASE_URL'>,
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;
}
+52 -6
View File
@@ -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.',
);
}
+19 -3
View File
@@ -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);
}
+189 -76
View File
@@ -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<ReturnType<typeof generateKeyPair>>['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<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address() as AddressInfo;
const supabaseUrl = `http://127.0.0.1:${address.port}`;
issuer = `${supabaseUrl}/auth/v1`;
provider = createSupabaseAuthProvider(supabaseUrl);
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<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
}),
);
await new Promise<void>((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<string, unknown>, tokenIssuer = issuer): Promise<string> {
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<string, unknown>,
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);
});
});
+3
View File
@@ -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() {
<BrowserRouter>
<AuthGate config={config} />
</BrowserRouter>
{/* Inside ThemeProvider: the host reads the resolved light/dark value. */}
<Toaster />
</ThemeProvider>
</QueryClientProvider>
);
+1 -1
View File
@@ -67,7 +67,7 @@ export function AdminSettings() {
<div className="relative overflow-hidden border-b border-border bg-surface-2 px-4 py-5 sm:px-6">
<div className="absolute -right-12 -top-20 size-48 rounded-full bg-accent-subtle blur-3xl" aria-hidden />
<div className="relative flex items-start gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-accent text-accent-on">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary text-accent-on">
<ShieldCheck aria-hidden />
</div>
<div className="min-w-0">
+28 -5
View File
@@ -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<AllocationRecord>('/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 ? <Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>{percent(match.score)} fit</Badge> : null}
</div>
<div className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface">
<div className="bg-accent" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
<div className="bg-accent/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
<div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
<div className="bg-primary/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-xs">
<div><p className="text-muted">Sold</p><p className="nums mt-0.5 font-medium">{compactNumber(row.soldGpuHours)} hrs</p></div>
@@ -137,7 +137,7 @@ export function GoogleSheetsSource({ onLoaded }: { onLoaded(table: GoogleParsedT
{files.isLoading ? <Skeleton className="h-40" /> : files.isError ? <ErrorText error={files.error} /> : files.data?.files.length === 0 ? <EmptyState title="No spreadsheets found" description="Try another name or confirm this Google account can see the spreadsheet." /> : (
<div className="grid gap-2 sm:grid-cols-2">
{files.data?.files.map((file) => (
<button key={file.id} type="button" onClick={() => { setSpreadsheetId(file.id); setSheetId(''); }} className={spreadsheetId === file.id ? 'tap min-w-0 rounded-lg border border-accent bg-accent-subtle p-3 text-left' : 'tap min-w-0 rounded-lg border border-border p-3 text-left hover:bg-surface-2'}>
<button key={file.id} type="button" onClick={() => { setSpreadsheetId(file.id); setSheetId(''); }} className={spreadsheetId === file.id ? 'tap min-w-0 rounded-lg border border-primary bg-accent-subtle p-3 text-left' : 'tap min-w-0 rounded-lg border border-border p-3 text-left hover:bg-surface-2'}>
<p className="truncate text-sm font-medium">{file.name}</p>
<p className="mt-1 text-xs text-muted">{file.modifiedTime ? `Modified ${relativeTime(file.modifiedTime)}` : 'Modified time unavailable'}</p>
</button>
+1 -1
View File
@@ -264,7 +264,7 @@ function PiggyChatPanel({
function ChatMessage({ message }: { message: TranscriptMessage }) {
if (message.role === 'user') {
return <div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-accent px-4 py-3 text-sm text-accent-on"><p className="whitespace-pre-wrap">{message.content}</p></div>;
return <div className="ml-auto max-w-[88%] rounded-2xl rounded-br-md bg-primary px-4 py-3 text-sm text-accent-on"><p className="whitespace-pre-wrap">{message.content}</p></div>;
}
return (
<div className="flex gap-3">
+15 -1
View File
@@ -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<HTMLDivElement>) {
return <div className={cn('card', className)} {...props} />;
return <div className={cn('card min-w-0', className)} {...props} />;
}
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
+30 -16
View File
@@ -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<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
export function Toaster(props: ToasterProps) {
const { resolved } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
theme={resolved}
className="toaster group"
// Above the sheets and dialogs it confirms, and clear of the phone tab
// bar and the home indicator beneath it.
position="bottom-right"
offset="1rem"
style={{ marginBottom: 'calc(var(--safe-bottom) + 4rem)' }}
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
'group toast group-[.toaster]:bg-surface group-[.toaster]:text-fg ' +
'group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-surface-2 group-[.toast]:text-muted',
error: 'group-[.toaster]:text-danger',
success: 'group-[.toaster]:text-positive',
},
}}
{...props}
/>
)
);
}
export { Toaster }
+2 -2
View File
@@ -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. */}
<div>
<div className="flex h-2 overflow-hidden rounded-full bg-surface-2">
<div className="bg-accent" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
<div className="bg-primary" style={{ width: `${Math.min(100, soldPct * 100)}%` }} />
<div
className="bg-accent/35"
className="bg-primary/35"
style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }}
/>
</div>
+1 -1
View File
@@ -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(' ')}
>
+12 -2
View File
@@ -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<DecisionResponse>(`/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() {
</Badge>
</header>
<Card className="overflow-hidden border-accent/30 bg-accent-subtle/40">
<Card className="overflow-hidden border-primary/30 bg-accent-subtle/40">
<CardContent className="flex gap-3 p-4 sm:p-5">
<FileCheck2 className="mt-0.5 size-5 shrink-0 text-accent-fg" aria-hidden />
<div className="min-w-0">
+1 -1
View File
@@ -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'}
>
<p className="font-semibold">{candidateDefinition.label}</p>
<p className="mt-1 text-xs leading-relaxed text-muted">{candidateDefinition.description}</p>
+1 -1
View File
@@ -184,7 +184,7 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
className={[
'tap shrink-0 rounded-full px-3.5 text-sm font-medium transition-colors',
stage === currentStage
? 'bg-accent text-accent-on'
? 'bg-primary text-accent-on'
: 'bg-surface-2 text-muted',
].join(' ')}
>
+1 -1
View File
@@ -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(' ')}
>
+6 -1
View File
@@ -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;
+16 -2
View File
@@ -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))',
+28
View File
@@ -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
+160
View File
@@ -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<string, unknown>;
}[] = [
{
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');