/** * 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().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().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;