import { bigint, boolean, index, integer, jsonb, pgEnum, pgTable, text, timestamp, uuid, varchar, } from 'drizzle-orm/pg-core'; export const planEnum = pgEnum('plan', ['hobby', 'pro', 'team', 'enterprise']); export const serverStatusEnum = pgEnum('server_status', [ 'draft', 'queued', 'generating', 'building', 'deploying', 'live', 'failed', 'paused', ]); export const buildStatusEnum = pgEnum('build_status', [ 'queued', 'generating', 'building', 'deploying', 'success', 'failed', 'cancelled', ]); export const organizations = pgTable('organizations', { id: uuid('id').defaultRandom().primaryKey(), slug: varchar('slug', { length: 64 }).notNull().unique(), name: varchar('name', { length: 128 }).notNull(), plan: planEnum('plan').default('hobby').notNull(), stripeCustomerId: varchar('stripe_customer_id', { length: 128 }), stripeSubscriptionId: varchar('stripe_subscription_id', { length: 128 }), monthlyCallQuota: bigint('monthly_call_quota', { mode: 'number' }).default(100_000).notNull(), callsThisPeriod: bigint('calls_this_period', { mode: 'number' }).default(0).notNull(), periodStartsAt: timestamp('period_starts_at').defaultNow().notNull(), suspended: boolean('suspended').default(false).notNull(), suspendedReason: text('suspended_reason'), createdAt: timestamp('created_at').defaultNow().notNull(), }); export const adminSettings = pgTable('admin_settings', { id: uuid('id').defaultRandom().primaryKey(), key: varchar('key', { length: 64 }).notNull().unique(), value: text('value'), updatedBy: uuid('updated_by').references(() => users.id, { onDelete: 'set null' }), updatedAt: timestamp('updated_at').defaultNow().notNull(), }); export const templateStatusEnum = pgEnum('template_status', [ 'draft', 'public', 'hidden', 'takedown', ]); export const templates = pgTable( 'templates', { id: uuid('id').defaultRandom().primaryKey(), ownerUserId: uuid('owner_user_id').references(() => users.id, { onDelete: 'set null' }), ownerOrgId: uuid('owner_org_id').references(() => organizations.id, { onDelete: 'set null' }), sourceServerId: uuid('source_server_id'), slug: varchar('slug', { length: 64 }).notNull().unique(), title: varchar('title', { length: 128 }).notNull(), shortDescription: varchar('short_description', { length: 280 }).notNull(), longDescription: text('long_description'), category: varchar('category', { length: 64 }).notNull(), toolsSchema: jsonb('tools_schema').notNull(), generatedCode: text('generated_code').notNull(), requiredSecrets: jsonb('required_secrets').notNull(), scopes: jsonb('scopes').notNull(), allowedDomains: jsonb('allowed_domains'), status: templateStatusEnum('status').default('public').notNull(), verified: boolean('verified').default(false).notNull(), takedownReason: text('takedown_reason'), forkCount: integer('fork_count').default(0).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }, (t) => ({ statusIdx: index('idx_templates_status').on(t.status, t.createdAt), categoryIdx: index('idx_templates_category').on(t.category), }), ); export const users = pgTable('users', { id: uuid('id').defaultRandom().primaryKey(), // Nullable: a user identifies via email OR phone. Postgres treats NULLs as // distinct, so multiple phone-only users (email NULL) coexist fine. email: varchar('email', { length: 255 }).unique(), phone: varchar('phone', { length: 32 }).unique(), name: varchar('name', { length: 128 }), avatarUrl: text('avatar_url'), emailVerified: boolean('email_verified').default(false).notNull(), isAdmin: boolean('is_admin').default(false).notNull(), passwordHash: text('password_hash'), lastLoginAt: timestamp('last_login_at'), createdAt: timestamp('created_at').defaultNow().notNull(), }); export const sessions = pgTable( 'sessions', { id: uuid('id').defaultRandom().primaryKey(), userId: uuid('user_id') .references(() => users.id, { onDelete: 'cascade' }) .notNull(), tokenHash: text('token_hash').notNull().unique(), expiresAt: timestamp('expires_at').notNull(), ipAddress: varchar('ip_address', { length: 64 }), userAgent: text('user_agent'), createdAt: timestamp('created_at').defaultNow().notNull(), }, (t) => ({ userIdx: index('idx_sessions_user').on(t.userId), }), ); export const magicLinks = pgTable('magic_links', { id: uuid('id').defaultRandom().primaryKey(), email: varchar('email', { length: 255 }).notNull(), tokenHash: text('token_hash').notNull().unique(), expiresAt: timestamp('expires_at').notNull(), consumedAt: timestamp('consumed_at'), createdAt: timestamp('created_at').defaultNow().notNull(), }); // Short-lived 6-digit SMS one-time codes for phone login. export const smsCodes = pgTable( 'sms_codes', { id: uuid('id').defaultRandom().primaryKey(), phone: varchar('phone', { length: 32 }).notNull(), codeHash: text('code_hash').notNull(), attempts: integer('attempts').default(0).notNull(), expiresAt: timestamp('expires_at').notNull(), consumedAt: timestamp('consumed_at'), createdAt: timestamp('created_at').defaultNow().notNull(), }, (t) => ({ phoneIdx: index('idx_sms_codes_phone').on(t.phone, t.createdAt), }), ); export const memberships = pgTable('memberships', { id: uuid('id').defaultRandom().primaryKey(), orgId: uuid('org_id') .references(() => organizations.id, { onDelete: 'cascade' }) .notNull(), userId: uuid('user_id') .references(() => users.id, { onDelete: 'cascade' }) .notNull(), role: varchar('role', { length: 32 }).default('owner').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), }); export const mcpServers = pgTable( 'mcp_servers', { id: uuid('id').defaultRandom().primaryKey(), orgId: uuid('org_id') .references(() => organizations.id, { onDelete: 'cascade' }) .notNull(), slug: varchar('slug', { length: 64 }).notNull(), name: varchar('name', { length: 128 }).notNull(), description: text('description'), status: serverStatusEnum('status').default('draft').notNull(), currentVersion: integer('current_version').default(0).notNull(), containerId: varchar('container_id', { length: 128 }), hostPort: integer('host_port'), publicUrl: text('public_url'), toolsSchema: jsonb('tools_schema'), oauthEnabled: boolean('oauth_enabled').default(true).notNull(), templateId: uuid('template_id'), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), }, (t) => ({ orgSlugIdx: index('idx_servers_org_slug').on(t.orgId, t.slug), templateIdx: index('idx_servers_template').on(t.templateId), }), ); export const builds = pgTable( 'builds', { id: uuid('id').defaultRandom().primaryKey(), serverId: uuid('server_id') .references(() => mcpServers.id, { onDelete: 'cascade' }) .notNull(), version: integer('version').notNull(), prompt: text('prompt').notNull(), generatedSpec: jsonb('generated_spec'), generatedCode: text('generated_code'), status: buildStatusEnum('status').default('queued').notNull(), errorMessage: text('error_message'), startedAt: timestamp('started_at'), finishedAt: timestamp('finished_at'), createdAt: timestamp('created_at').defaultNow().notNull(), }, (t) => ({ serverIdx: index('idx_builds_server').on(t.serverId), }), ); export const buildLogs = pgTable( 'build_logs', { id: uuid('id').defaultRandom().primaryKey(), buildId: uuid('build_id') .references(() => builds.id, { onDelete: 'cascade' }) .notNull(), level: varchar('level', { length: 16 }).default('info').notNull(), message: text('message').notNull(), timestamp: timestamp('timestamp').defaultNow().notNull(), }, (t) => ({ buildIdx: index('idx_logs_build').on(t.buildId, t.timestamp), }), ); // Envelope encryption: a Data Encryption Key (DEK) is generated, wrapped (itself // AES-256-GCM encrypted) with the Key Encryption Key (KEK) from the env, and // stored here. Secrets are encrypted with the DEK. Rotation mints a fresh DEK // and re-encrypts every secret — the KEK never leaves the environment. export const encryptionKeys = pgTable('encryption_keys', { id: uuid('id').defaultRandom().primaryKey(), version: integer('version').notNull().unique(), wrappedDek: text('wrapped_dek').notNull(), active: boolean('active').default(false).notNull(), rotatedBy: uuid('rotated_by').references(() => users.id, { onDelete: 'set null' }), createdAt: timestamp('created_at').defaultNow().notNull(), retiredAt: timestamp('retired_at'), }); export const secrets = pgTable('secrets', { id: uuid('id').defaultRandom().primaryKey(), serverId: uuid('server_id') .references(() => mcpServers.id, { onDelete: 'cascade' }) .notNull(), key: varchar('key', { length: 128 }).notNull(), encryptedValue: text('encrypted_value').notNull(), // null = legacy (encrypted directly with the KEK, pre-envelope). Non-null // rows are encrypted with the referenced key's DEK. keyId: uuid('key_id').references(() => encryptionKeys.id), createdAt: timestamp('created_at').defaultNow().notNull(), }); export const oauthClients = pgTable('oauth_clients', { id: uuid('id').defaultRandom().primaryKey(), // Nullable: RFC 7591 Dynamic Client Registration treats the `resource` // claim as optional, so a client may register generically and only bind // to a specific server at /oauth/authorize. /authorize enforces the // org-ownership check on every authorization either way. serverId: uuid('server_id').references(() => mcpServers.id, { onDelete: 'cascade', }), clientId: varchar('client_id', { length: 128 }).notNull().unique(), clientSecretHash: text('client_secret_hash'), redirectUris: jsonb('redirect_uris').notNull(), metadata: jsonb('metadata'), createdAt: timestamp('created_at').defaultNow().notNull(), }); export const oauthCodes = pgTable('oauth_codes', { id: uuid('id').defaultRandom().primaryKey(), clientDbId: uuid('client_db_id') .references(() => oauthClients.id, { onDelete: 'cascade' }) .notNull(), code: varchar('code', { length: 128 }).notNull().unique(), codeChallenge: varchar('code_challenge', { length: 256 }).notNull(), codeChallengeMethod: varchar('code_challenge_method', { length: 16 }).notNull(), redirectUri: text('redirect_uri').notNull(), scope: text('scope'), resource: text('resource'), userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }), expiresAt: timestamp('expires_at').notNull(), consumedAt: timestamp('consumed_at'), createdAt: timestamp('created_at').defaultNow().notNull(), }); export const oauthTokens = pgTable('oauth_tokens', { id: uuid('id').defaultRandom().primaryKey(), clientDbId: uuid('client_db_id') .references(() => oauthClients.id, { onDelete: 'cascade' }) .notNull(), accessTokenHash: text('access_token_hash').notNull(), refreshTokenHash: text('refresh_token_hash'), scope: text('scope'), resource: text('resource'), // The JWT `sub` claim of the issued access token. Stored so that a // refresh_token-grant request can mint a new access token with the SAME // subject as the original authorization, without re-walking the (now // consumed) authorization code. Falls back to client_id for M2M grants. subject: text('subject'), // Row-level expiry — represents the refresh-token's lifetime. Access tokens // carry their own `exp` inside the JWT; the server doesn't need to track // access expiry separately. expiresAt: timestamp('expires_at').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), }); export const toolCallMetrics = pgTable( 'tool_call_metrics', { id: uuid('id').defaultRandom().primaryKey(), serverId: uuid('server_id') .references(() => mcpServers.id, { onDelete: 'cascade' }) .notNull(), toolName: varchar('tool_name', { length: 128 }).notNull(), durationMs: integer('duration_ms'), success: boolean('success').notNull(), errorCode: varchar('error_code', { length: 64 }), timestamp: timestamp('timestamp').defaultNow().notNull(), }, (t) => ({ serverTimeIdx: index('idx_metrics_server_time').on(t.serverId, t.timestamp), }), ); export const auditLog = pgTable('audit_log', { id: uuid('id').defaultRandom().primaryKey(), orgId: uuid('org_id').references(() => organizations.id, { onDelete: 'set null' }), userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }), action: varchar('action', { length: 128 }).notNull(), resourceType: varchar('resource_type', { length: 64 }), resourceId: varchar('resource_id', { length: 128 }), metadata: jsonb('metadata'), ipAddress: varchar('ip_address', { length: 64 }), createdAt: timestamp('created_at').defaultNow().notNull(), }); // In-app support ticketing replaces the email contact channel. Anonymous // (logged-out) tickets are allowed via the public /contact form so we still // satisfy UWG Art. 3 lit. s (Swiss "easy electronic contact" requirement). export const supportStatusEnum = pgEnum('support_status', [ 'awaiting_admin', 'awaiting_user', 'closed', ]); export const supportTickets = pgTable( 'support_tickets', { id: uuid('id').defaultRandom().primaryKey(), userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }), orgId: uuid('org_id').references(() => organizations.id, { onDelete: 'set null' }), // For anonymous /contact submissions: collect email so admin can reply. guestEmail: varchar('guest_email', { length: 255 }), subject: varchar('subject', { length: 200 }).notNull(), status: supportStatusEnum('status').default('awaiting_admin').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().notNull(), lastMessageAt: timestamp('last_message_at').defaultNow().notNull(), closedAt: timestamp('closed_at'), }, (t) => ({ userIdx: index('idx_support_tickets_user').on(t.userId), statusIdx: index('idx_support_tickets_status').on(t.status, t.lastMessageAt), }), ); export const supportMessages = pgTable( 'support_messages', { id: uuid('id').defaultRandom().primaryKey(), ticketId: uuid('ticket_id') .references(() => supportTickets.id, { onDelete: 'cascade' }) .notNull(), authorUserId: uuid('author_user_id').references(() => users.id, { onDelete: 'set null' }), authorIsAdmin: boolean('author_is_admin').default(false).notNull(), body: text('body').notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), }, (t) => ({ ticketIdx: index('idx_support_messages_ticket').on(t.ticketId, t.createdAt), }), ); export type Organization = typeof organizations.$inferSelect; export type User = typeof users.$inferSelect; export type Session = typeof sessions.$inferSelect; export type McpServer = typeof mcpServers.$inferSelect; export type Build = typeof builds.$inferSelect; export type BuildLog = typeof buildLogs.$inferSelect; export type Secret = typeof secrets.$inferSelect; export type OAuthClient = typeof oauthClients.$inferSelect; export type Template = typeof templates.$inferSelect; export type EncryptionKey = typeof encryptionKeys.$inferSelect; export type SupportTicket = typeof supportTickets.$inferSelect; export type SupportMessage = typeof supportMessages.$inferSelect;