-- Phase F: paliad.user_caldav_config — per-user CalDAV credentials and sync state. -- -- Each user can configure ONE external CalDAV calendar (one row per user). -- Credentials are stored encrypted at rest using AES-GCM with a 12-byte -- random nonce prepended to the ciphertext. The encryption key comes from -- the CALDAV_ENCRYPTION_KEY env var (32-byte AES-256, base64-encoded). -- -- Audit §1.3 fix: this is the migration that closes the KanzlAI plaintext -- credential issue. Application code refuses to read/write encrypted blobs -- when CALDAV_ENCRYPTION_KEY is unset. -- -- Sync model: a per-user goroutine, spawned at server startup for every row -- with enabled = true, pushes/pulls every 60 seconds. Conflict resolution is -- last-write-wins on the modified timestamp; conflicts on Akte-attached -- termine append a row to paliad.akten_events. CREATE TABLE paliad.user_caldav_config ( user_id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, url text NOT NULL, username text NOT NULL, password_encrypted bytea NOT NULL, calendar_path text NOT NULL DEFAULT '', enabled boolean NOT NULL DEFAULT true, last_sync_at timestamptz, last_sync_error text, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); -- Recent sync errors per user (last 5 retained). Separate table so the -- main row stays small and we can show an audit trail in the UI. CREATE TABLE paliad.caldav_sync_log ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, occurred_at timestamptz NOT NULL DEFAULT now(), direction text NOT NULL CHECK (direction IN ('push', 'pull', 'both')), items_pushed int NOT NULL DEFAULT 0, items_pulled int NOT NULL DEFAULT 0, error text, duration_ms int ); CREATE INDEX caldav_sync_log_user_time_idx ON paliad.caldav_sync_log (user_id, occurred_at DESC); -- RLS: a user can only see and manage their own row. Encrypted password -- never leaves the database except through the service layer (and even -- there, the API never returns it). ALTER TABLE paliad.user_caldav_config ENABLE ROW LEVEL SECURITY; CREATE POLICY user_caldav_self_select ON paliad.user_caldav_config FOR SELECT TO authenticated USING (user_id = auth.uid()); CREATE POLICY user_caldav_self_insert ON paliad.user_caldav_config FOR INSERT TO authenticated WITH CHECK (user_id = auth.uid()); CREATE POLICY user_caldav_self_update ON paliad.user_caldav_config FOR UPDATE TO authenticated USING (user_id = auth.uid()) WITH CHECK (user_id = auth.uid()); CREATE POLICY user_caldav_self_delete ON paliad.user_caldav_config FOR DELETE TO authenticated USING (user_id = auth.uid()); ALTER TABLE paliad.caldav_sync_log ENABLE ROW LEVEL SECURITY; CREATE POLICY caldav_sync_log_self_select ON paliad.caldav_sync_log FOR SELECT TO authenticated USING (user_id = auth.uid());