space-game/supabase/migrations/005_user_tokens.sql
Michael Mainguy 9440be3251 Add level change history system with database trigger
- Create level_history table storing previous and new state snapshots
- Add trigger that automatically logs meaningful changes to levels
- Track change_type: update, submitted, published, rejected
- RLS policies for user and admin access to history
- Add RPC functions for querying history and entries
- Set user context in save_level_by_token for proper attribution

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 07:46:31 -06:00

192 lines
5.7 KiB
PL/PgSQL

-- ===========================================
-- USER TOKENS MIGRATION
-- API tokens for editor plugin authentication
-- ===========================================
-- ===========================================
-- USER_TOKENS TABLE
-- ===========================================
CREATE TABLE IF NOT EXISTS user_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Editor Token',
token_hash TEXT NOT NULL, -- SHA256 hash of token
token_prefix TEXT NOT NULL, -- First 8 chars for display (e.g., "abc12345...")
created_at TIMESTAMPTZ DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ, -- NULL = never expires
is_revoked BOOLEAN DEFAULT FALSE
);
CREATE INDEX idx_user_tokens_user_id ON user_tokens(user_id);
CREATE INDEX idx_user_tokens_hash ON user_tokens(token_hash);
-- ===========================================
-- FUNCTION: Create a new token for current user
-- Returns the raw token (only time it's visible)
-- ===========================================
CREATE OR REPLACE FUNCTION create_user_token(p_name TEXT DEFAULT 'Editor Token')
RETURNS TEXT AS $$
DECLARE
v_user_id UUID;
v_raw_token TEXT;
v_token_hash TEXT;
BEGIN
-- Get current user's internal ID
v_user_id := auth_user_id();
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'Not authenticated';
END IF;
-- Generate secure random token (32 bytes = 64 hex chars)
v_raw_token := encode(gen_random_bytes(32), 'hex');
v_token_hash := encode(sha256(v_raw_token::bytea), 'hex');
-- Insert token record
INSERT INTO user_tokens (user_id, name, token_hash, token_prefix)
VALUES (v_user_id, p_name, v_token_hash, substring(v_raw_token, 1, 8));
-- Return raw token (only time user sees it)
RETURN v_raw_token;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ===========================================
-- FUNCTION: Validate token and return user_id
-- Used by plugin to authenticate requests
-- ===========================================
CREATE OR REPLACE FUNCTION validate_user_token(p_token TEXT)
RETURNS UUID AS $$
DECLARE
v_token_hash TEXT;
v_user_id UUID;
BEGIN
v_token_hash := encode(sha256(p_token::bytea), 'hex');
SELECT user_id INTO v_user_id
FROM user_tokens
WHERE token_hash = v_token_hash
AND is_revoked = FALSE
AND (expires_at IS NULL OR expires_at > NOW());
-- Update last_used_at if valid
IF v_user_id IS NOT NULL THEN
UPDATE user_tokens
SET last_used_at = NOW()
WHERE token_hash = v_token_hash;
END IF;
RETURN v_user_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ===========================================
-- FUNCTION: Revoke a token
-- ===========================================
CREATE OR REPLACE FUNCTION revoke_user_token(p_token_id UUID)
RETURNS BOOLEAN AS $$
DECLARE
v_user_id UUID;
BEGIN
v_user_id := auth_user_id();
IF v_user_id IS NULL THEN
RETURN FALSE;
END IF;
UPDATE user_tokens
SET is_revoked = TRUE
WHERE id = p_token_id AND user_id = v_user_id;
RETURN FOUND;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- ===========================================
-- RLS POLICIES
-- ===========================================
ALTER TABLE user_tokens ENABLE ROW LEVEL SECURITY;
-- Users can only see their own tokens
CREATE POLICY "user_tokens_select_own" ON user_tokens FOR SELECT
USING (user_id = auth_user_id());
-- Users can only delete their own tokens
CREATE POLICY "user_tokens_delete_own" ON user_tokens FOR DELETE
USING (user_id = auth_user_id());
-- Insert/update via functions only (SECURITY DEFINER)
-- ===========================================
-- RPC FUNCTIONS FOR PLUGIN ACCESS
-- These bypass RLS using the editor token
-- ===========================================
-- Get user's levels using editor token
CREATE OR REPLACE FUNCTION get_my_levels_by_token(p_token TEXT)
RETURNS TABLE (
id UUID,
name TEXT,
description TEXT,
difficulty TEXT,
level_type TEXT,
config JSONB,
updated_at TIMESTAMPTZ
) AS $$
DECLARE
v_user_id UUID;
BEGIN
v_user_id := validate_user_token(p_token);
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'Invalid or expired token';
END IF;
RETURN QUERY
SELECT l.id, l.name, l.description, l.difficulty, l.level_type, l.config, l.updated_at
FROM levels l
WHERE l.user_id = v_user_id
ORDER BY l.updated_at DESC;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Save/update a level using editor token
CREATE OR REPLACE FUNCTION save_level_by_token(
p_token TEXT,
p_name TEXT,
p_difficulty TEXT,
p_config JSONB,
p_level_id UUID DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
v_user_id UUID;
v_result_id UUID;
BEGIN
v_user_id := validate_user_token(p_token);
IF v_user_id IS NULL THEN
RAISE EXCEPTION 'Invalid or expired token';
END IF;
-- Set user context for history trigger (defined in migration 006)
PERFORM set_config('app.current_user_id', v_user_id::TEXT, true);
IF p_level_id IS NOT NULL THEN
-- Update existing level (only if owned by user)
UPDATE levels
SET name = p_name, difficulty = p_difficulty, config = p_config, updated_at = NOW()
WHERE id = p_level_id AND user_id = v_user_id
RETURNING id INTO v_result_id;
IF v_result_id IS NULL THEN
RAISE EXCEPTION 'Level not found or not owned by user';
END IF;
ELSE
-- Create new level
INSERT INTO levels (user_id, name, difficulty, config, level_type)
VALUES (v_user_id, p_name, p_difficulty, p_config, 'private')
RETURNING id INTO v_result_id;
END IF;
RETURN v_result_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;