-- 993_jwt_revocations.sql
-- v1.0.10 sécu item 6 — DB-backed JWT revocation ledger.
--
-- Background : the platform already had a Redis-based TokenBlacklist
-- (services/token_blacklist.go, T0174) that hashes the whole token
-- and writes a Redis key with TTL. That works for the happy path —
-- logout, refresh rotation — but has two gaps :
--   1. Redis restart wipes the blacklist. A token revoked 10s before
--      a Redis crash becomes valid again until its natural expiry.
--   2. No way to revoke "session #3 of user X" from an admin UI :
--      the blacklist is keyed by token-hash, not by jti. The admin
--      doesn't have the user's token at hand.
--
-- This table fixes both : durable per-jti revocations that survive
-- Redis restarts, and a stable id (the jti claim) the admin UI can
-- target.
--
-- The middleware checks Redis first (fast path) and falls back to
-- this table on miss (cache miss after restart). On revoke, both
-- Redis and the table are written ; the worst case is "double check"
-- on a known-revoked token, never "missed" revocation.

CREATE TABLE IF NOT EXISTS public.jwt_revocations (
    -- The JWT's `jti` claim. Stored as VARCHAR (not UUID) because the
    -- claim is a string per RFC 7519 ; we generate UUIDs but other
    -- callers may not.
    jti          VARCHAR(64) PRIMARY KEY,
    user_id      UUID NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
    -- The token's natural expiry. Used by the cleanup cron to drop
    -- rows that no longer matter — once the token would have expired
    -- on its own, the revocation row is redundant.
    expires_at   TIMESTAMPTZ NOT NULL,
    revoked_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    -- Optional free-text reason (e.g. "user-initiated logout",
    -- "admin revocation: lost device", "automatic: refresh rotation").
    -- Surface in the user's "active sessions" UI so they understand
    -- why a session ended.
    reason       VARCHAR(255),
    -- Who triggered the revocation. NULL = system / automatic
    -- (refresh rotation, expiry-driven), non-NULL = user or admin
    -- decision worth auditing.
    revoked_by   UUID REFERENCES public.users(id) ON DELETE SET NULL
);

COMMENT ON TABLE public.jwt_revocations IS
    'Per-jti JWT revocation ledger. Survives Redis restarts. v1.0.10 sécu item 6.';
COMMENT ON COLUMN public.jwt_revocations.jti IS
    'JWT ID claim (RFC 7519). Generated as UUID by JWTService.GenerateAccessToken / GenerateRefreshToken.';
COMMENT ON COLUMN public.jwt_revocations.expires_at IS
    'Natural token expiry. Cleanup cron drops rows where expires_at < now() - safety_margin.';

-- Lookup by user_id : "list all revocations for this user" — admin
-- audit screen.
CREATE INDEX IF NOT EXISTS idx_jwt_revocations_user
    ON public.jwt_revocations(user_id);

-- Cleanup index : the cron runs `DELETE FROM jwt_revocations WHERE
-- expires_at < $1` ; partial index on already-expired rows would be
-- empty during steady state, so a regular B-tree is what we want.
CREATE INDEX IF NOT EXISTS idx_jwt_revocations_expires_at
    ON public.jwt_revocations(expires_at);
