-- ========================================== -- Chat Switchboard — 001 Core -- ========================================== -- Users (with auth fields), refresh tokens, platform policies, -- global settings, user presence. -- Consolidated v0.28.0: merges 001 + 018 (auth_abstraction) + 016 (presence). -- ========================================== -- ── Extensions ────────────────────────────── CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid() CREATE EXTENSION IF NOT EXISTS "vector"; -- pgvector (KB embeddings) -- ── Utility: auto-update updated_at ───────── CREATE OR REPLACE FUNCTION update_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; -- ========================================= -- USERS -- ========================================= CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), username VARCHAR(50) NOT NULL, email VARCHAR(255) NOT NULL, password_hash TEXT, -- nullable for mTLS/OIDC display_name VARCHAR(100), avatar_url TEXT, role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('user', 'admin')), is_active BOOLEAN DEFAULT true, settings JSONB DEFAULT '{}'::jsonb, -- Auth abstraction (v0.24.0) auth_source VARCHAR(20) NOT NULL DEFAULT 'builtin' CHECK (auth_source IN ('builtin', 'mtls', 'oidc')), external_id TEXT, handle VARCHAR(100) NOT NULL, -- Vault: per-user encryption key (UEK) for BYOK API keys encrypted_uek BYTEA, uek_salt BYTEA, uek_nonce BYTEA, vault_set BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), last_login_at TIMESTAMPTZ ); -- Case-insensitive unique indexes CREATE UNIQUE INDEX IF NOT EXISTS users_username_ci ON users (LOWER(username)); CREATE UNIQUE INDEX IF NOT EXISTS users_email_ci ON users (LOWER(email)); CREATE UNIQUE INDEX IF NOT EXISTS idx_users_handle ON users(LOWER(handle)); CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external_id ON users(auth_source, external_id) WHERE external_id IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); DROP TRIGGER IF EXISTS users_updated_at ON users; CREATE TRIGGER users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at(); COMMENT ON COLUMN users.auth_source IS 'builtin=password, mtls=cert, oidc=SSO'; COMMENT ON COLUMN users.external_id IS 'IdP subject claim (OIDC) or cert fingerprint (mTLS)'; COMMENT ON COLUMN users.handle IS 'Unique @mention handle, auto-generated from username'; COMMENT ON COLUMN users.encrypted_uek IS 'UEK encrypted with Argon2id(password)-derived key'; COMMENT ON COLUMN users.uek_salt IS 'Argon2id salt for UEK derivation'; COMMENT ON COLUMN users.uek_nonce IS 'AES-GCM nonce for UEK wrapping'; COMMENT ON COLUMN users.vault_set IS 'true once UEK has been generated and wrapped'; -- ========================================= -- AUTH — REFRESH TOKENS -- ========================================= CREATE TABLE IF NOT EXISTS refresh_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, token_hash TEXT NOT NULL UNIQUE, expires_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), revoked_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL; -- ========================================= -- PLATFORM POLICIES -- ========================================= CREATE TABLE IF NOT EXISTS platform_policies ( key VARCHAR(50) PRIMARY KEY, value TEXT NOT NULL, updated_by UUID REFERENCES users(id), updated_at TIMESTAMPTZ DEFAULT NOW() ); INSERT INTO platform_policies (key, value) VALUES ('allow_user_byok', 'false'), ('allow_user_personas', 'false'), ('allow_raw_model_access', 'true'), ('allow_registration', 'true'), ('default_user_active', 'false'), ('allow_team_providers', 'true'), ('kb_direct_access', 'true') ON CONFLICT (key) DO NOTHING; COMMENT ON TABLE platform_policies IS 'Global admin switches controlling platform behavior'; -- ========================================= -- GLOBAL SETTINGS -- ========================================= CREATE TABLE IF NOT EXISTS global_settings ( key VARCHAR(100) PRIMARY KEY, value JSONB NOT NULL DEFAULT '{}'::jsonb, updated_at TIMESTAMPTZ DEFAULT NOW(), updated_by UUID REFERENCES users(id) ); INSERT INTO global_settings (key, value) VALUES ('registration', '{"enabled": true}'::jsonb), ('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb), ('banner', '{"enabled": false, "text": "", "bg": "#007a33", "fg": "#ffffff"}'::jsonb), ('model_roles', '{ "utility": { "primary": null, "fallback": null }, "embedding": { "primary": null, "fallback": null }, "generation": { "primary": null, "fallback": null } }'::jsonb), ('retention_ttl_days', '{"value": 0}'::jsonb) ON CONFLICT (key) DO NOTHING; -- ========================================= -- USER PRESENCE (v0.23.1) -- ========================================= CREATE TABLE IF NOT EXISTS user_presence ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(), status VARCHAR(20) NOT NULL DEFAULT 'online' CHECK (status IN ('online', 'away', 'offline')) ); CREATE INDEX IF NOT EXISTS idx_user_presence_last_seen ON user_presence(last_seen DESC); COMMENT ON TABLE user_presence IS 'Heartbeat-driven presence. Rows upserted every 30s by active clients.';