Merge remote-tracking branch 'gitea/main' into feat/revenue-intelligence
CI / verify (push) Successful in 2m54s
CI / verify (push) Successful in 2m54s
This commit is contained in:
@@ -22,6 +22,27 @@ SUPABASE_ANON_KEY=
|
|||||||
# PIG runs fine in invite-only mode. Treat it as the most powerful secret here.
|
# PIG runs fine in invite-only mode. Treat it as the most powerful secret here.
|
||||||
SUPABASE_SERVICE_KEY=
|
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 ------------------------------------------------------------
|
# --- Application ------------------------------------------------------------
|
||||||
PIG_PORT=8920
|
PIG_PORT=8920
|
||||||
PIG_PUBLIC_URL=http://localhost:8920
|
PIG_PUBLIC_URL=http://localhost:8920
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
**Authentication is not authorization.** A verified JWT proves someone has an
|
||||||
account in an identity provider that PIG *shares with another application*. It
|
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`
|
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
|
flag set to false was silently on. Use the `envBoolean` helper in
|
||||||
`apps/api/src/lib/config.ts`.
|
`apps/api/src/lib/config.ts`.
|
||||||
|
|
||||||
**Grid children that truncate need `min-w-0`.** Grid items default to
|
**Mutations must confirm themselves.** `<Toaster />` is mounted in `App.tsx`
|
||||||
`min-width: auto` and `truncate` sets `nowrap`, so a long title becomes
|
inside `ThemeProvider`; use `toast.success` / `toast.error` in every mutation's
|
||||||
unshrinkable content and the page scrolls sideways on a phone.
|
`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`
|
**Drizzle-generated migrations are not always valid SQL.** A `jsonb → integer`
|
||||||
cast was emitted without the `USING` clause Postgres requires. Always apply a
|
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
|
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.
|
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
|
**Prime Intellect has two API hosts.** `api.primeintellect.ai` is compute and
|
||||||
pods. Inference is `api.pinference.ai/api/v1`, OpenAI-compatible.
|
pods. Inference is `api.pinference.ai/api/v1`, OpenAI-compatible.
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ test('invite-bound member creates, sells and observes capacity through authentic
|
|||||||
PIGGY_ENABLED: 'false',
|
PIGGY_ENABLED: 'false',
|
||||||
});
|
});
|
||||||
const authProvider = {
|
const authProvider = {
|
||||||
|
name: 'e2e-stub',
|
||||||
async verifyAccessToken(token: string) {
|
async verifyAccessToken(token: string) {
|
||||||
if (token !== accessToken) throw new Error('Invalid E2E token.');
|
if (token !== accessToken) throw new Error('Invalid E2E token.');
|
||||||
return { subject, email };
|
return { subject, email };
|
||||||
|
|||||||
@@ -4,8 +4,22 @@
|
|||||||
* Providers prove an external identity. They do not decide whether that
|
* Providers prove an external identity. They do not decide whether that
|
||||||
* identity belongs to PIG; workspace membership remains a database decision
|
* identity belongs to PIG; workspace membership remains a database decision
|
||||||
* in the authenticator and signup route.
|
* 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';
|
import type { Config } from './config';
|
||||||
|
|
||||||
export interface VerifiedIdentity {
|
export interface VerifiedIdentity {
|
||||||
@@ -14,6 +28,8 @@ export interface VerifiedIdentity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthProvider {
|
export interface AuthProvider {
|
||||||
|
/** Human-readable, for startup logging and the health surface. */
|
||||||
|
readonly name: string;
|
||||||
verifyAccessToken(token: string): Promise<VerifiedIdentity>;
|
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`));
|
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
name: 'supabase',
|
||||||
async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
|
async verifyAccessToken(token: string): Promise<VerifiedIdentity> {
|
||||||
const { payload } = await jwtVerify(token, jwks, { issuer });
|
const { payload } = await jwtVerify(token, jwks, { issuer });
|
||||||
if (!payload.sub) throw new Error('token has no subject');
|
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(
|
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 {
|
): 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;
|
return config.SUPABASE_URL ? createSupabaseAuthProvider(config.SUPABASE_URL) : null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,21 @@ const schema = z.object({
|
|||||||
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'),
|
DATABASE_URL: z.string().min(1, 'DATABASE_URL is required.'),
|
||||||
|
|
||||||
SUPABASE_URL: z.string().url().optional(),
|
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_ANON_KEY: z.string().optional(),
|
||||||
SUPABASE_SERVICE_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.');
|
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(
|
warn(
|
||||||
'SUPABASE_URL is not set — authentication is DISABLED and every request ' +
|
'No identity provider is configured (SUPABASE_URL or PIG_OIDC_ISSUER) — ' +
|
||||||
'runs as the development user. Never do this in production.',
|
'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(
|
throw new Error(
|
||||||
'Refusing to start: NODE_ENV=production with no SUPABASE_URL would serve ' +
|
'Refusing to start: NODE_ENV=production with neither SUPABASE_URL nor ' +
|
||||||
'the entire CRM unauthenticated.',
|
'PIG_OIDC_ISSUER would serve the entire CRM unauthenticated.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-3
@@ -57,9 +57,25 @@ if (existsSync(webDist)) {
|
|||||||
return serveStatic({ root: './apps/web/dist' })(c, next);
|
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.
|
* Client-side routes (/margin, /capacity, …) have no file on disk and must
|
||||||
app.get('*', serveStatic({ path: './apps/web/dist/index.html' }));
|
* 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);
|
console.log('[pig] serving front end from', webDist);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* This is the code that decides whether a stranger is who they claim to be, so
|
||||||
* creation. Membership remains deliberately outside this module.
|
* 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 { 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 { createServer, type Server } from 'node:http';
|
||||||
import type { AddressInfo } from 'node:net';
|
import type { AddressInfo } from 'node:net';
|
||||||
import { after, before, describe, it } from 'node:test';
|
import { SignJWT, exportJWK, generateKeyPair, type JWK } from 'jose';
|
||||||
import { exportJWK, SignJWT } from 'jose';
|
import { createOidcAuthProvider, createConfiguredAuthProvider } from '../src/lib/auth-provider';
|
||||||
import {
|
|
||||||
createSupabaseAuthProvider,
|
|
||||||
type AuthProvider,
|
|
||||||
} from '../src/lib/auth-provider';
|
|
||||||
|
|
||||||
describe('Supabase auth provider', () => {
|
let server: Server;
|
||||||
let server: Server;
|
let origin: string;
|
||||||
let provider: AuthProvider;
|
// Derived from jose rather than referencing the DOM `CryptoKey` type, which
|
||||||
let issuer: string;
|
// is not in this package's type lib.
|
||||||
let privateKey: KeyObject;
|
type SigningKey = Awaited<ReturnType<typeof generateKeyPair>>['privateKey'];
|
||||||
|
|
||||||
before(async () => {
|
let privateKey: SigningKey;
|
||||||
const keys = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
let otherPrivateKey: SigningKey;
|
||||||
privateKey = keys.privateKey;
|
let jwks: { keys: JWK[] };
|
||||||
const publicJwk = await exportJWK(keys.publicKey);
|
/** Flipped per test to exercise discovery failures. */
|
||||||
|
let discoveryStatus = 200;
|
||||||
|
let serveDiscovery = true;
|
||||||
|
|
||||||
server = createServer((request, response) => {
|
before(async () => {
|
||||||
if (request.url !== '/auth/v1/.well-known/jwks.json') {
|
const pair = await generateKeyPair('RS256');
|
||||||
response.writeHead(404).end();
|
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;
|
return;
|
||||||
}
|
}
|
||||||
response.setHeader('content-type', 'application/json');
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
response.end(
|
res.end(JSON.stringify({ issuer: origin, jwks_uri: `${origin}/jwks` }));
|
||||||
JSON.stringify({
|
return;
|
||||||
keys: [{ ...publicJwk, alg: 'RS256', kid: 'test-key', use: 'sig' }],
|
}
|
||||||
}),
|
if (req.url === '/jwks') {
|
||||||
);
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(jwks));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
server.once('error', reject);
|
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
|
||||||
server.listen(0, '127.0.0.1', resolve);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const address = server.address() as AddressInfo;
|
after(() => server?.close());
|
||||||
const supabaseUrl = `http://127.0.0.1:${address.port}`;
|
|
||||||
issuer = `${supabaseUrl}/auth/v1`;
|
|
||||||
provider = createSupabaseAuthProvider(supabaseUrl);
|
|
||||||
});
|
|
||||||
|
|
||||||
after(
|
async function mint(
|
||||||
() =>
|
claims: Record<string, unknown>,
|
||||||
new Promise<void>((resolve, reject) => {
|
opts: { key?: SigningKey; issuer?: string; expiresIn?: string } = {},
|
||||||
server.close((error) => (error ? reject(error) : resolve()));
|
) {
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
async function sign(claims: Record<string, unknown>, tokenIssuer = issuer): Promise<string> {
|
|
||||||
return new SignJWT(claims)
|
return new SignJWT(claims)
|
||||||
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
.setProtectedHeader({ alg: 'RS256', kid: 'test-key' })
|
||||||
.setIssuer(tokenIssuer)
|
.setIssuedAt()
|
||||||
.setExpirationTime('5m')
|
.setIssuer(opts.issuer ?? origin)
|
||||||
.sign(privateKey);
|
.setExpirationTime(opts.expiresIn ?? '5m')
|
||||||
|
.sign(opts.key ?? privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
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('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('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('returns only the verified external identity claims', async () => {
|
|
||||||
const token = await sign({ sub: 'provider-user-1', email: 'Owner@Example.com' });
|
|
||||||
|
|
||||||
assert.deepEqual(await provider.verifyAccessToken(token), {
|
|
||||||
subject: 'provider-user-1',
|
|
||||||
email: 'Owner@Example.com',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a correctly signed token issued for a different identity provider', async () => {
|
it('falls back through email claims for providers that do not send `email`', async () => {
|
||||||
// Signature validity alone is insufficient: without the issuer check, a
|
const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||||
// sibling deployment using the same key could authenticate here.
|
const token = await mint({ sub: 'user-4', preferred_username: 'someone@corp.com', aud: 'pig' });
|
||||||
const token = await sign({ sub: 'provider-user-1' }, 'https://other.example/auth/v1');
|
assert.equal((await provider.verifyAccessToken(token)).email, 'someone@corp.com');
|
||||||
|
|
||||||
await assert.rejects(provider.verifyAccessToken(token));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts a subject without email for protected requests', async () => {
|
it('ignores a preferred_username that is not an address', async () => {
|
||||||
// Existing members are joined by subject. Email is required only by the
|
// A bare username must never become an account identity — PIG keys
|
||||||
// invite-gated profile flow, not as an extra condition on every request.
|
// membership on the email, and "jsmith" is not one.
|
||||||
const token = await sign({ sub: 'provider-user-2' });
|
const provider = createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||||
|
const token = await mint({ sub: 'user-5', preferred_username: 'jsmith', aud: 'pig' });
|
||||||
assert.deepEqual(await provider.verifyAccessToken(token), {
|
assert.equal((await provider.verifyAccessToken(token)).email, undefined);
|
||||||
subject: 'provider-user-2',
|
});
|
||||||
email: undefined,
|
});
|
||||||
});
|
|
||||||
});
|
describe('OIDC provider — what it must refuse', () => {
|
||||||
|
const provider = () => createOidcAuthProvider({ issuer: origin, audience: 'pig' });
|
||||||
it('rejects a token with no stable subject', async () => {
|
|
||||||
const token = await sign({ email: 'owner@example.com' });
|
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));
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { CreateProfile } from '@/pages/CreateProfile';
|
|||||||
import { Register } from '@/pages/Register';
|
import { Register } from '@/pages/Register';
|
||||||
import { PiggyMark } from '@/components/PiggyMark';
|
import { PiggyMark } from '@/components/PiggyMark';
|
||||||
import { EmptyState } from '@/components/ui';
|
import { EmptyState } from '@/components/ui';
|
||||||
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { usePageTitle } from '@/lib/title';
|
import { usePageTitle } from '@/lib/title';
|
||||||
|
|
||||||
const Overview = lazy(() => import('@/pages/Overview').then(({ Overview }) => ({ default: Overview })));
|
const Overview = lazy(() => import('@/pages/Overview').then(({ Overview }) => ({ default: Overview })));
|
||||||
@@ -79,6 +80,8 @@ export function App() {
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthGate config={config} />
|
<AuthGate config={config} />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
{/* Inside ThemeProvider: the host reads the resolved light/dark value. */}
|
||||||
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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="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="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="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 />
|
<ShieldCheck aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
} from '@/components/ui/sheet';
|
} from '@/components/ui/sheet';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { ApiError, compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
|
import { ApiError, compactNumber, get, money, percent, post, shortDate } from '@/lib/api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export interface AvailabilityRow {
|
export interface AvailabilityRow {
|
||||||
commitmentId: string;
|
commitmentId: string;
|
||||||
@@ -295,10 +296,24 @@ export function AllocationSheet({
|
|||||||
})
|
})
|
||||||
: post<AllocationRecord>('/api/allocations', { ...body, status: values.status });
|
: post<AllocationRecord>('/api/allocations', { ...body, status: values.status });
|
||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async (allocation, values) => {
|
||||||
await refresh();
|
await refresh();
|
||||||
onOpenChange(false);
|
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({
|
const release = useMutation({
|
||||||
mutationFn: (id: string) =>
|
mutationFn: (id: string) =>
|
||||||
@@ -306,8 +321,16 @@ export function AllocationSheet({
|
|||||||
reason: releaseReason.trim() || undefined,
|
reason: releaseReason.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
onMutate: () => setReleaseError(null),
|
onMutate: () => setReleaseError(null),
|
||||||
onSuccess: refresh,
|
onSuccess: async () => {
|
||||||
onError: (error) => setReleaseError(errorMessage(error)),
|
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) => {
|
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}
|
{match ? <Badge tone={match.score > 0.7 ? 'positive' : 'neutral'}>{percent(match.score)} fit</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex h-2 overflow-hidden rounded-full bg-surface">
|
<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-primary" 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/35" style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 grid grid-cols-3 gap-2 text-xs">
|
<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>
|
<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." /> : (
|
{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">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
{files.data?.files.map((file) => (
|
{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="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>
|
<p className="mt-1 text-xs text-muted">{file.modifiedTime ? `Modified ${relativeTime(file.modifiedTime)}` : 'Modified time unavailable'}</p>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ function PiggyChatPanel({
|
|||||||
|
|
||||||
function ChatMessage({ message }: { message: TranscriptMessage }) {
|
function ChatMessage({ message }: { message: TranscriptMessage }) {
|
||||||
if (message.role === 'user') {
|
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 (
|
return (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
@@ -83,8 +83,22 @@ Input.displayName = 'Input';
|
|||||||
|
|
||||||
// --------------------------------------------------------------------- card
|
// --------------------------------------------------------------------- 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>) {
|
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>) {
|
export function CardHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
|||||||
@@ -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>
|
export function Toaster(props: ToasterProps) {
|
||||||
|
const { resolved } = useTheme();
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
|
||||||
const { theme = "system" } = useTheme()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={theme as ToasterProps["theme"]}
|
theme={resolved}
|
||||||
className="toaster group"
|
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={{
|
toastOptions={{
|
||||||
classNames: {
|
classNames: {
|
||||||
toast:
|
toast:
|
||||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
'group toast group-[.toaster]:bg-surface group-[.toaster]:text-fg ' +
|
||||||
description: "group-[.toast]:text-muted-foreground",
|
'group-[.toaster]:border-border group-[.toaster]:shadow-lg',
|
||||||
actionButton:
|
description: 'group-[.toast]:text-muted',
|
||||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
|
||||||
cancelButton:
|
cancelButton: 'group-[.toast]:bg-surface-2 group-[.toast]:text-muted',
|
||||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
error: 'group-[.toaster]:text-danger',
|
||||||
|
success: 'group-[.toaster]:text-positive',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Toaster }
|
|
||||||
|
|||||||
@@ -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. */}
|
bar made mostly of unconverted holds is a lie a seller would act on. */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex h-2 overflow-hidden rounded-full bg-surface-2">
|
<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
|
<div
|
||||||
className="bg-accent/35"
|
className="bg-primary/35"
|
||||||
style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }}
|
style={{ width: `${Math.min(100 - soldPct * 100, heldPct * 100)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function CreateProfile({
|
|||||||
className={[
|
className={[
|
||||||
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
|
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
|
||||||
team === value
|
team === value
|
||||||
? 'border-accent bg-accent-subtle'
|
? 'border-primary bg-accent-subtle'
|
||||||
: 'border-border hover:bg-surface-2',
|
: 'border-border hover:bg-surface-2',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { get, patch, relativeTime } from '@/lib/api';
|
import { get, patch, relativeTime } from '@/lib/api';
|
||||||
import { can } from '@/lib/permissions';
|
import { can } from '@/lib/permissions';
|
||||||
import { usePageTitle } from '@/lib/title';
|
import { usePageTitle } from '@/lib/title';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface ReviewItem {
|
interface ReviewItem {
|
||||||
fact: SourcedFact;
|
fact: SourcedFact;
|
||||||
@@ -74,7 +75,16 @@ export function FactReview() {
|
|||||||
const decision = useMutation({
|
const decision = useMutation({
|
||||||
mutationFn: ({ id, status }: { id: string; status: 'approved' | 'dismissed' }) =>
|
mutationFn: ({ id, status }: { id: string; status: 'approved' | 'dismissed' }) =>
|
||||||
patch<DecisionResponse>(`/api/facts/${id}/decision`, { status }),
|
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'] });
|
await queryClient.invalidateQueries({ queryKey: ['facts', 'proposed'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -99,7 +109,7 @@ export function FactReview() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</header>
|
</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">
|
<CardContent className="flex gap-3 p-4 sm:p-5">
|
||||||
<FileCheck2 className="mt-0.5 size-5 shrink-0 text-accent-fg" aria-hidden />
|
<FileCheck2 className="mt-0.5 size-5 shrink-0 text-accent-fg" aria-hidden />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export function Imports() {
|
|||||||
type="button"
|
type="button"
|
||||||
aria-pressed={entity === candidate}
|
aria-pressed={entity === candidate}
|
||||||
onClick={() => resetForEntity(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="font-semibold">{candidateDefinition.label}</p>
|
||||||
<p className="mt-1 text-xs leading-relaxed text-muted">{candidateDefinition.description}</p>
|
<p className="mt-1 text-xs leading-relaxed text-muted">{candidateDefinition.description}</p>
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ function PipelineBoard<T extends { id: string; stage: string; updatedAt: string
|
|||||||
className={[
|
className={[
|
||||||
'tap shrink-0 rounded-full px-3.5 text-sm font-medium transition-colors',
|
'tap shrink-0 rounded-full px-3.5 text-sm font-medium transition-colors',
|
||||||
stage === currentStage
|
stage === currentStage
|
||||||
? 'bg-accent text-accent-on'
|
? 'bg-primary text-accent-on'
|
||||||
: 'bg-surface-2 text-muted',
|
: 'bg-surface-2 text-muted',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export function Register({
|
|||||||
className={[
|
className={[
|
||||||
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
|
'flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors',
|
||||||
team === value
|
team === value
|
||||||
? 'border-accent bg-accent-subtle'
|
? 'border-primary bg-accent-subtle'
|
||||||
: 'border-border hover:bg-surface-2',
|
: 'border-border hover:bg-surface-2',
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Input } from '
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { usePageTitle } from '@/lib/title';
|
import { usePageTitle } from '@/lib/title';
|
||||||
import { AdminSettings } from '@/components/AdminSettings';
|
import { AdminSettings } from '@/components/AdminSettings';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface Me {
|
interface Me {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -93,7 +94,7 @@ function Appearance() {
|
|||||||
title={option.label}
|
title={option.label}
|
||||||
className={[
|
className={[
|
||||||
'tap relative flex items-center gap-2 rounded-lg border px-3 py-2 text-sm font-medium transition-colors',
|
'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(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{/*
|
{/*
|
||||||
@@ -144,7 +145,11 @@ function Profile({ me }: { me: Me | undefined }) {
|
|||||||
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
void queryClient.invalidateQueries({ queryKey: ['me'] });
|
||||||
setName('');
|
setName('');
|
||||||
setTitle('');
|
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;
|
if (!me) return null;
|
||||||
|
|||||||
@@ -20,8 +20,22 @@ export default {
|
|||||||
foreground: 'hsl(var(--fg))',
|
foreground: 'hsl(var(--fg))',
|
||||||
muted: 'hsl(var(--muted))',
|
muted: 'hsl(var(--muted))',
|
||||||
'muted-foreground': '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-fg': 'hsl(var(--accent-fg))',
|
||||||
'accent-on': 'hsl(var(--accent-on))',
|
'accent-on': 'hsl(var(--accent-on))',
|
||||||
'accent-subtle': 'hsl(var(--accent-subtle))',
|
'accent-subtle': 'hsl(var(--accent-subtle))',
|
||||||
|
|||||||
@@ -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
|
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
|
## A note on the auth project
|
||||||
|
|
||||||
PIG verifies JWTs but authorizes from its own `users` table. If the Supabase
|
PIG verifies JWTs but authorizes from its own `users` table. If the Supabase
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { createDatabase } from '../client';
|
|||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
activities,
|
activities,
|
||||||
|
facts,
|
||||||
allocations,
|
allocations,
|
||||||
capacityCommitments,
|
capacityCommitments,
|
||||||
capacityRequests,
|
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(' 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(' 6 demand deals across the pipeline, 5 supply deals');
|
||||||
console.log(' Allocations including one unconverted hold and internal research burn');
|
console.log(' Allocations including one unconverted hold and internal research burn');
|
||||||
console.log('\nEverything is prefixed "DEMO — ". Remove it with: npm run db:demo -- --clear');
|
console.log('\nEverything is prefixed "DEMO — ". Remove it with: npm run db:demo -- --clear');
|
||||||
|
|||||||
Reference in New Issue
Block a user