-- ============================================================================ -- 0004_credentials_vault.sql -- ---------------------------------------------------------------------------- -- Per-user encrypted credential store for surface adapters. -- -- Dual-mode auth: 'cookie' (paste-once, no captcha exposure) and 'credentials' -- (full re-login with username/password/totp). Both paths converge on the -- same Playwright runtime once authenticated. -- -- Encryption: AES-256-GCM at-rest in *_enc columns. DEK is delivered per- -- session by the custodian process on black via SSE; falls back to -- CREDENTIALS_ENCRYPTION_KEY env in dev. Encrypted blob format: -- "::" -- -- Spec source: _engineering-credentials-vault.md §"Step 1 — schema port". -- ============================================================================ CREATE TYPE credential_platform_type AS ENUM ( 'escort', 'content', 'screening', 'reviews', 'socials', 'messaging', 'travel', 'other' ); CREATE TYPE credential_auth_mode AS ENUM ('cookie', 'credentials'); CREATE TABLE credentials ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, org_id UUID NULL REFERENCES orgs(id) ON DELETE CASCADE, -- Identity platform TEXT NOT NULL, -- canonical surface id (e.g. 'tryst') platform_name TEXT, -- display name platform_type credential_platform_type NOT NULL DEFAULT 'escort', category TEXT, -- soft taxonomy bucket url TEXT, -- login page or root url -- Auth-mode discriminator auth_mode credential_auth_mode NOT NULL DEFAULT 'credentials', -- credentials-mode fields (NULL when auth_mode='cookie') username TEXT, password_enc TEXT, totp_secret_enc TEXT, -- cookie-mode fields (NULL when auth_mode='credentials') cookie_blob_enc TEXT, -- shared note TEXT, sort_order INT NOT NULL DEFAULT 0, is_archived BOOLEAN NOT NULL DEFAULT FALSE, -- mode-switch keeps prior auth for revert last_verified_at TIMESTAMPTZ NULL, -- last time adapter confirmed the session works created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT credentials_auth_mode_shape CHECK ( (auth_mode = 'credentials' AND username IS NOT NULL AND password_enc IS NOT NULL AND cookie_blob_enc IS NULL) OR (auth_mode = 'cookie' AND cookie_blob_enc IS NOT NULL AND password_enc IS NULL AND totp_secret_enc IS NULL) ) ); -- One active row per (user, platform, username). Archived rows ignored; -- this lets mode-switch keep prior creds without unique conflicts. CREATE UNIQUE INDEX idx_credentials_user_platform_username_active ON credentials (user_id, lower(platform), lower(coalesce(username, '__cookie__'))) WHERE NOT is_archived; CREATE INDEX idx_credentials_user_platform ON credentials (user_id, platform) WHERE NOT is_archived; CREATE INDEX idx_credentials_org_platform ON credentials (org_id, platform) WHERE org_id IS NOT NULL AND NOT is_archived; CREATE TRIGGER trg_credentials_updated_at BEFORE UPDATE ON credentials FOR EACH ROW EXECUTE FUNCTION touch_updated_at(); -- ---------------------------------------------------------------------------- -- RLS — tenant isolation -- ---------------------------------------------------------------------------- ALTER TABLE credentials ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_isolation_credentials ON credentials USING ( user_id = current_setting('app.user_id', true)::uuid OR (org_id IS NOT NULL AND org_id = current_setting('app.org_id', true)::uuid) ) WITH CHECK ( user_id = current_setting('app.user_id', true)::uuid OR (org_id IS NOT NULL AND org_id = current_setting('app.org_id', true)::uuid) ); -- ---------------------------------------------------------------------------- -- Per-user attribution salt (referenced by _engineering-surface-metrics §9) -- ---------------------------------------------------------------------------- ALTER TABLE users ADD COLUMN IF NOT EXISTS attribution_salt BYTEA NULL; COMMENT ON COLUMN users.attribution_salt IS 'Per-user 32-byte salt for identifier_hash in prospect_touchpoints. Generated lazily on first touchpoint write. NEVER exposed via API.';