/** * The HTTP application. * * Deliberately thin. Following the rule stated in the README — *intelligence * never lives in the API* — these handlers validate input, check authorization, * call a service, and serialise the result. Research, enrichment, scoring and * matching heuristics live in the service layer or in the agent, never here. */ import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { logger } from 'hono/logger'; import { and, desc, eq, ilike, isNull, or, sql } from 'drizzle-orm'; import { z } from 'zod'; import type { Database } from '@pig/db'; import { accounts, activities, allocations, capacityCommitments, contacts, contracts, demandDeals, supplyDeals, teamMemberships, users, } from '@pig/db'; import { ACCENTS, DEMAND_STAGES, SECURITY_TIERS, SUPPLY_STAGES, TEAMS, THEME_MODES, isValidAccent, isValidThemeMode, } from '@pig/core'; import type { Config } from './lib/config'; import { AuthError, createAuthenticator, effectivePermissions, type Principal, } from './lib/auth'; import { createConfiguredAuthProvider, type AuthProvider, } from './lib/auth-provider'; import { apiError } from './lib/mutation'; import { createMediaRoutes } from './lib/media'; import { CapacityService } from './services/capacity'; import { createSignupRoute } from './routes/signup'; import { createRegisterRoute } from './routes/register'; import { createDemandStageMutation } from './routes/deals'; import { createFactsRoute } from './routes/facts'; import { createApiKeyRoutes } from './routes/api-keys'; import { createCapacityWriteRoutes } from './routes/capacity-writes'; import { createRecordRoutes } from './routes/records'; import { createImportRoutes } from './routes/imports'; import { createGoogleSheetsRoutes } from './routes/google-sheets'; import { createContractRoutes } from './routes/contracts'; import { createPiggyChatRoutes, platformPiggyEnabled } from './routes/piggy-chat'; import { createAdminSettingsRoutes } from './routes/admin-settings'; import { createSlackRoutes, SLACK_CAPACITY_COMMAND_PATH } from './routes/slack'; import { createBuzzRoutes } from './routes/buzz'; import { createIntegrationSettingsRoutes } from './routes/integration-settings'; import { createNotionImportRoutes, NOTION_OAUTH_CALLBACK_PATH } from './routes/notion-import'; import { createGrowthRoutes } from './routes/growth'; import { createCalendarRoutes } from './routes/calendar'; import { createLearnRoutes, LEARN_ACCESS_PATH, LEARN_PUBLIC_PATH } from './routes/learn'; import { createReadGuardRoutes } from './routes/read-guards'; import { createActivityRoutes } from './routes/activities'; import { NotificationOutbox } from './services/notification-outbox'; type Env = { Variables: { principal: Principal } }; export function createApp( config: Config, db: Database, authProvider: AuthProvider | null = createConfiguredAuthProvider(config), runtime: { onPlatformSettingsChanged?: () => Promise } = {}, ) { const app = new Hono(); const auth = createAuthenticator(config, db, authProvider); const capacity = new CapacityService(db); const notifications = new NotificationOutbox(db); if (!config.isProduction) app.use('*', logger()); app.use( '/api/*', cors({ // In production the front end is served from the same origin, so no // cross-origin allowance is needed. In development Vite runs separately. origin: config.isProduction ? config.PIG_PUBLIC_URL : ['http://localhost:5173'], credentials: true, }), ); /* * Profile creation. Mounted BEFORE the auth middleware because it is the * route that turns an authenticated stranger into a member — requiring * membership to reach it would be circular. It verifies the token itself. */ app.route('/', createSignupRoute(config, db, authProvider)); /* * Registration. Also before the auth middleware, and necessarily so: the * caller has no account yet, so there is no token to present. The invite * code is the only gate, which is why it is validated before anything is * created anywhere. */ app.route('/', createRegisterRoute(config, db)); /** Liveness. Unauthenticated by design so a load balancer can reach it. */ app.get('/api/health', (c) => c.json({ ok: true, service: 'pig', version: '0.1.0' })); /** * Public configuration for the front end — what it needs before anyone has * logged in. Contains only values that are safe in a browser: the anon key is * designed to be public, and no secret is exposed here. */ app.get('/api/config', (c) => c.json({ supabaseUrl: config.SUPABASE_URL ?? null, supabaseAnonKey: config.SUPABASE_ANON_KEY ?? null, authDisabled: !config.SUPABASE_URL, inviteRequired: Boolean(config.PIG_INVITE_CODE), // Whether someone with an invite can create an account outright, or must // be provisioned by an administrator first. canSelfRegister: Boolean(config.SUPABASE_URL && config.SUPABASE_SERVICE_KEY), accents: ACCENTS.map((a) => ({ key: a.key, label: a.label })), teams: TEAMS, }), ); /* * Learn videos PIG serves itself. Mounted here — before the authenticator, * and before server.ts's SPA fallback — because a