Files
pig/packages/db/src/schema/identity.ts
T
karti 7aeec0c632 Add Prime Intellect client, API, and MCP server
packages/prime — a hand-written typed client, because the first-party SDK is
Python only. Deliberately narrow: PIG reads availability and nothing else, and
the key it holds should be scoped so it could not provision even if the code
tried. Rate limits are undocumented upstream, so it backs off empirically with
full jitter and honours Retry-After. Unknown fields survive in `raw` rather
than being dropped.

apps/api — Hono, with authentication and authorization kept firmly apart. A
verified JWT proves someone has an account in the identity project, which may
be shared with other applications; it does NOT prove they belong here. Access
requires a row in PIG's own users table, and a token without one gets 403
needs_profile rather than entry.

The capacity service is the business logic: availability counts sold and held
separately, so a live hold removes inventory from everyone else's availability
without inflating utilisation. Expired holds are ignored at read time, so the
numbers stay right even when the sweeper is behind. Matching treats
interconnect as a hard filter and excludes Unknown as well as Ethernet —
unverified is not the same as adequate.

apps/mcp — nine tools over stdio, so a team member drives PIG from Claude
Code, Codex, prime-agent, or a Buzz agent. It holds an API key and calls the
same HTTP API the browser does, with no database credentials, so an agent can
never reach further than the person it acts for. Results are formatted as
prose rather than raw JSON.

Theme preferences live in the database rather than localStorage, so a chosen
accent follows someone from laptop to phone. Status colours stay independent
of the accent: if "at risk" re-tinted to whatever a user picked, the signal
would be gone.

Note on the SDK import: its package exports use a `./*` wildcard whose types
entry resolves server/mcp.js to server/mcp.js.d.ts, which does not exist. The
runtime specifier must keep the .js suffix, so the types are mapped via
tsconfig paths rather than by writing an import that would fail at runtime.

Verified: all five packages typecheck; the MCP server constructs and registers
its tools.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 19:02:45 -07:00

172 lines
6.4 KiB
TypeScript

/**
* Identity and authorization.
*
* PIG authenticates with an external identity provider but authorizes from this
* table. The distinction is load-bearing: the auth project may be shared with
* other applications, so "has a valid token" must never imply "may use PIG".
* A token gets you as far as `GET /api/me`, which returns 404 until an
* administrator or a valid invite creates the row below.
*/
import {
boolean,
index,
jsonb,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex,
uuid,
} from 'drizzle-orm/pg-core';
import { teamEnum, teamRoleEnum } from './enums';
export const users = pgTable(
'users',
{
id: uuid('id').primaryKey().defaultRandom(),
/**
* The external identity provider's subject claim. This is the join to
* authentication and the only thing a bearer token proves.
* Nullable so that a person can be invited before they first sign in.
*/
authSubject: uuid('auth_subject'),
email: text('email').notNull(),
name: text('name').notNull(),
/** Short handle used in mentions and CLI output. */
handle: text('handle'),
avatarUrl: text('avatar_url'),
title: text('title'),
timezone: text('timezone'),
/**
* Platform administration is separate from being a member of any team, and
* separate again from being an employee of the organisation PIG tracks.
* Conflating the three is how an operator quietly ends up recorded as staff
* of a company they do not work for.
*/
isPlatformAdmin: boolean('is_platform_admin').notNull().default(false),
/**
* Appearance preferences, persisted server-side rather than in
* localStorage so a person's chosen look follows them between their
* laptop and their phone. `system` defers to the OS.
*/
themeMode: text('theme_mode').notNull().default('system'),
/**
* Accent colour key from the shared palette in @pig/core. The whole
* interface re-tints from this one value. Stored as a key rather than a
* hex string so the palette can be retuned centrally — and so a user
* cannot pick something illegible against the surface colours.
*/
accentColor: text('accent_color').notNull().default('pig'),
/** Set when the person stops using PIG. Rows are retained for audit. */
deactivatedAt: timestamp('deactivated_at', { withTimezone: true }),
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('users_auth_subject_key').on(t.authSubject),
uniqueIndex('users_email_key').on(t.email),
uniqueIndex('users_handle_key').on(t.handle),
],
);
/**
* Team membership. A person may belong to several teams — in a small company
* the same individual routinely sells capacity and sources it — so this is a
* join table rather than a column on `users`.
*/
export const teamMemberships = pgTable(
'team_memberships',
{
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
team: teamEnum('team').notNull(),
role: teamRoleEnum('role').notNull().default('member'),
/** The team shown on sign-in when someone belongs to more than one. */
isPrimary: boolean('is_primary').notNull().default(false),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
primaryKey({ columns: [t.userId, t.team] }),
index('team_memberships_team_idx').on(t.team),
],
);
/**
* Invitations. Self-serve profile creation is gated on one of these rather than
* on the identity provider's own signup setting, which may be shared with
* unrelated applications and toggled by someone with no knowledge of PIG.
*/
export const invites = pgTable(
'invites',
{
id: uuid('id').primaryKey().defaultRandom(),
/** Hashed, never stored in the clear. */
codeHash: text('code_hash').notNull(),
/** Optional pinning to one address; null allows any recipient. */
email: text('email'),
team: teamEnum('team'),
role: teamRoleEnum('role').notNull().default('member'),
createdByUserId: uuid('created_by_user_id').references(() => users.id, {
onDelete: 'set null',
}),
expiresAt: timestamp('expires_at', { withTimezone: true }),
/** Number of times this invite may still be redeemed. */
usesRemaining: jsonb('uses_remaining').$type<number>().notNull().default(1),
redeemedByUserId: uuid('redeemed_by_user_id').references(() => users.id, {
onDelete: 'set null',
}),
redeemedAt: timestamp('redeemed_at', { withTimezone: true }),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('invites_code_hash_key').on(t.codeHash),
index('invites_email_idx').on(t.email),
],
);
/**
* API keys, for the CLI and for MCP clients connecting over HTTP.
*
* An agent acting for a person is not the same principal as that person: it
* gets its own key, its own audit trail, and can be revoked without disturbing
* the human's session. Only the hash is stored, so a leaked database does not
* yield working credentials.
*/
export const apiKeys = pgTable(
'api_keys',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
keyHash: text('key_hash').notNull(),
/** Non-secret leading fragment, so a key can be identified in a list. */
keyPrefix: text('key_prefix').notNull(),
/** Coarse scopes. `read` is the default and is enough for most agent use. */
scopes: jsonb('scopes').$type<string[]>().notNull().default(['read']),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
expiresAt: timestamp('expires_at', { withTimezone: true }),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => [
uniqueIndex('api_keys_key_hash_key').on(t.keyHash),
index('api_keys_user_idx').on(t.userId),
],
);
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type TeamMembership = typeof teamMemberships.$inferSelect;
export type ApiKey = typeof apiKeys.$inferSelect;