-- Developer credentials are opaque application tokens, never Supabase sessions. -- Only the trusted server can manage/consume them; the raw token is not retained. create table public.developer_api_keys ( id uuid primary key default gen_random_uuid(), owner_id uuid not null references public.profiles(id) on delete cascade, name text not null check (length(btrim(name)) between 1 and 60), prefix text not null check (prefix ~ '^[A-Za-z0-9_-]{8,24}$'), token_hash text not null unique check (token_hash ~ '^[a-f0-9]{64}$'), scopes text[] not null check ( cardinality(scopes) between 1 and 4 and array_ndims(scopes)=1 and array_position(scopes,null) is null and scopes <@ array[ 'maps:read','account:read','account:maps:read','account:games:read']::text[]), created_at timestamptz not null default clock_timestamp(), expires_at timestamptz not null, revoked_at timestamptz, last_used_at timestamptz, rate_window timestamptz, rate_count integer not null default 0 check (rate_count between 0 and 60) ); create index developer_api_keys_owner_created on public.developer_api_keys(owner_id,created_at desc); create table public.developer_api_owner_limits ( owner_id uuid primary key references public.profiles(id) on delete cascade, rate_window timestamptz not null, rate_count integer not null default 0 check (rate_count between 0 and 180) ); alter table public.developer_api_keys enable row level security; alter table public.developer_api_owner_limits enable row level security; revoke all on public.developer_api_keys, public.developer_api_owner_limits from public, anon, authenticated; grant select, insert, update, delete on public.developer_api_keys, public.developer_api_owner_limits to service_role; create policy "Server manages developer keys" on public.developer_api_keys for all to service_role using (true) with check (true); create policy "Server manages developer quotas" on public.developer_api_owner_limits for all to service_role using (true) with check (true); create function public.developer_keys_list(p_owner uuid) returns jsonb language plpgsql security invoker set search_path = '' as $$ begin -- profiles.id references auth.users ON DELETE CASCADE, so this also checks -- account existence without granting service_role access to auth internals. if not exists(select 1 from public.profiles p where p.id=p_owner) then raise exception using errcode='42501', message='Account required'; end if; -- Bounded metadata history, with every active key retained before old records. -- Explicit field selection deliberately excludes token_hash and quota state. return (select coalesce(jsonb_agg(jsonb_build_object( 'id',k.id,'name',k.name,'prefix',k.prefix,'scopes',k.scopes, 'createdAt',k.created_at,'expiresAt',k.expires_at,'revokedAt',k.revoked_at,'lastUsedAt',k.last_used_at ) order by k.active desc,k.created_at desc,k.id desc),'[]'::jsonb) from ( select d.*, (d.revoked_at is null and d.expires_at>clock_timestamp()) as active from public.developer_api_keys d where d.owner_id=p_owner order by active desc,d.created_at desc,d.id desc limit 100 ) k); end; $$; create function public.developer_key_create(p_owner uuid,p_name text,p_scopes text[],p_token_hash text,p_prefix text,p_expires_days integer) returns jsonb language plpgsql security invoker set search_path = '' as $$ declare k public.developer_api_keys; normalized_scopes text[]; begin if p_name is null or length(btrim(p_name)) not between 1 and 60 or p_token_hash is null or p_token_hash !~ '^[a-f0-9]{64}$' or p_prefix is null or p_prefix !~ '^[A-Za-z0-9_-]{8,24}$' or p_expires_days is null or p_expires_days not between 1 and 365 then raise exception using errcode='22023', message='Invalid API key settings'; end if; if p_scopes is null or cardinality(p_scopes) not between 1 and 4 or array_ndims(p_scopes)<>1 or array_position(p_scopes,null) is not null or not p_scopes <@ array[ 'maps:read','account:read','account:maps:read','account:games:read']::text[] then raise exception using errcode='22023', message='Invalid API key scopes'; end if; select array_agg(distinct s order by s) into normalized_scopes from unnest(p_scopes) s; -- A single owner lock serializes both key creation and quota consumption. perform p.id from public.profiles p where p.id=p_owner for update; if not found then raise exception using errcode='42501', message='Account required'; end if; if (select count(*) from public.developer_api_keys where owner_id=p_owner and revoked_at is null and expires_at>clock_timestamp())>=5 then raise exception using errcode='22023', message='Maximum 5 active API keys'; end if; insert into public.developer_api_keys(owner_id,name,prefix,token_hash,scopes,expires_at) values(p_owner,btrim(p_name),p_prefix,p_token_hash,normalized_scopes,clock_timestamp()+make_interval(days=>p_expires_days)) returning * into k; return jsonb_build_object('id',k.id,'name',k.name,'prefix',k.prefix,'scopes',k.scopes, 'createdAt',k.created_at,'expiresAt',k.expires_at,'revokedAt',k.revoked_at,'lastUsedAt',k.last_used_at); end; $$; create function public.developer_key_revoke(p_owner uuid,p_key uuid) returns boolean language plpgsql security invoker set search_path = '' as $$ begin perform p.id from public.profiles p where p.id=p_owner for update; if not found then raise exception using errcode='42501', message='Account required'; end if; update public.developer_api_keys set revoked_at=coalesce(revoked_at,clock_timestamp()) where owner_id=p_owner and id=p_key; return found; end; $$; create function public.developer_key_consume(p_token_hash text) returns jsonb language plpgsql security invoker set search_path = '' set statement_timeout = '10s' as $$ declare k public.developer_api_keys; limits public.developer_api_owner_limits; owner uuid; checked_at timestamptz; window_at timestamptz; reset_at bigint; key_count integer; owner_count integer; begin if p_token_hash is null or p_token_hash !~ '^[a-f0-9]{64}$' then return jsonb_build_object('ok',false,'error','invalid_key'); end if; select owner_id into owner from public.developer_api_keys where token_hash=p_token_hash; if not found then return jsonb_build_object('ok',false,'error','invalid_key'); end if; -- Every consume/create/revoke takes the same owner lock first, then the key. -- This ensures concurrent keys cannot overspend a shared account allowance. perform p.id from public.profiles p where p.id=owner for update; if not found then return jsonb_build_object('ok',false,'error','invalid_key'); end if; checked_at:=clock_timestamp(); window_at:=date_trunc('minute',checked_at); reset_at:=extract(epoch from window_at+interval '1 minute')::bigint; select * into k from public.developer_api_keys where token_hash=p_token_hash and owner_id=owner and revoked_at is null and expires_at>checked_at for update; if not found then return jsonb_build_object('ok',false,'error','invalid_key'); end if; insert into public.developer_api_owner_limits(owner_id,rate_window) values(owner,window_at) on conflict(owner_id) do nothing; select * into limits from public.developer_api_owner_limits where owner_id=owner for update; key_count:=case when k.rate_window=window_at then k.rate_count else 0 end; owner_count:=case when limits.rate_window=window_at then limits.rate_count else 0 end; if key_count>=60 or owner_count>=180 then return jsonb_build_object('ok',false,'error','rate_limited','limit',60,'remaining',0, 'ownerLimit',180,'ownerRemaining',greatest(180-owner_count,0),'resetAt',reset_at); end if; update public.developer_api_keys set rate_window=window_at,rate_count=key_count+1,last_used_at=checked_at where id=k.id; update public.developer_api_owner_limits set rate_window=window_at,rate_count=owner_count+1 where owner_id=owner; return jsonb_build_object('ok',true,'keyId',k.id,'ownerId',owner,'scopes',k.scopes, 'limit',60,'remaining',59-key_count,'ownerLimit',180,'ownerRemaining',179-owner_count,'resetAt',reset_at); end; $$; revoke all on function public.developer_keys_list(uuid) from public, anon, authenticated; revoke all on function public.developer_key_create(uuid,text,text[],text,text,integer) from public, anon, authenticated; revoke all on function public.developer_key_revoke(uuid,uuid) from public, anon, authenticated; revoke all on function public.developer_key_consume(text) from public, anon, authenticated; grant execute on function public.developer_keys_list(uuid) to service_role; grant execute on function public.developer_key_create(uuid,text,text[],text,text,integer) to service_role; grant execute on function public.developer_key_revoke(uuid,uuid) to service_role; grant execute on function public.developer_key_consume(text) to service_role;