Add developer API, Prometheus metrics, and community links
This commit is contained in:
128
supabase/migrations/20261012124943_developer_api_keys.sql
Normal file
128
supabase/migrations/20261012124943_developer_api_keys.sql
Normal file
@@ -0,0 +1,128 @@
|
||||
-- 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;
|
||||
Reference in New Issue
Block a user