Add developer API, Prometheus metrics, and community links
This commit is contained in:
@@ -1 +0,0 @@
|
||||
v2.120.0
|
||||
102
supabase/migrations/20261012085217_prometheus_metrics.sql
Normal file
102
supabase/migrations/20261012085217_prometheus_metrics.sql
Normal file
@@ -0,0 +1,102 @@
|
||||
-- Credentials and cached aggregates are server-only. No raw token is retained.
|
||||
create table public.prometheus_settings (
|
||||
id boolean primary key default true check (id),
|
||||
token_hash text check (token_hash ~ '^[a-f0-9]{64}$'),
|
||||
updated_by uuid references public.profiles(id) on delete set null,
|
||||
updated_at timestamptz,
|
||||
last_scraped_at timestamptz,
|
||||
snapshot jsonb,
|
||||
snapshot_at timestamptz
|
||||
);
|
||||
insert into public.prometheus_settings(id) values (true);
|
||||
alter table public.prometheus_settings enable row level security;
|
||||
revoke all on public.prometheus_settings from public, anon, authenticated;
|
||||
grant select, update on public.prometheus_settings to service_role;
|
||||
create policy "Server manages metrics integration" on public.prometheus_settings
|
||||
for all to service_role using (true) with check (true);
|
||||
|
||||
create function public.prometheus_status(p_reviewer uuid) returns jsonb
|
||||
language plpgsql security invoker set search_path = '' as $$
|
||||
begin
|
||||
if not exists (select 1 from public.profiles where id=p_reviewer and role='admin') then
|
||||
raise exception using errcode='42501', message='Admin permission required';
|
||||
end if;
|
||||
return (select jsonb_build_object(
|
||||
'enabled', s.token_hash is not null and exists(select 1 from public.profiles p where p.id=s.updated_by and p.role='admin'),
|
||||
'updatedAt', s.updated_at, 'lastScrapedAt', s.last_scraped_at
|
||||
) from public.prometheus_settings s where s.id);
|
||||
end; $$;
|
||||
|
||||
create function public.configure_prometheus(p_reviewer uuid, p_token_hash text) returns jsonb
|
||||
language plpgsql security invoker set search_path = '' as $$
|
||||
begin
|
||||
if not exists (select 1 from public.profiles where id=p_reviewer and role='admin') then
|
||||
raise exception using errcode='42501', message='Admin permission required';
|
||||
end if;
|
||||
if p_token_hash is not null and p_token_hash !~ '^[a-f0-9]{64}$' then raise exception 'Invalid metrics token hash'; end if;
|
||||
update public.prometheus_settings set token_hash=p_token_hash, updated_by=p_reviewer,
|
||||
updated_at=clock_timestamp(), last_scraped_at=null, snapshot=null, snapshot_at=null where id;
|
||||
return public.prometheus_status(p_reviewer);
|
||||
end; $$;
|
||||
|
||||
create index if not exists game_sessions_metrics_created on public.game_sessions(created_at);
|
||||
create index if not exists multiplayer_rooms_metrics_created on public.multiplayer_rooms(created_at);
|
||||
|
||||
create function public.read_prometheus_metrics(p_token_hash text) returns jsonb
|
||||
language plpgsql security invoker set search_path = '' set statement_timeout = '15s' as $$
|
||||
declare settings public.prometheus_settings; collected_at timestamptz := clock_timestamp(); result jsonb;
|
||||
begin
|
||||
-- The token grants only access to aggregate metrics. Demoting/deleting its
|
||||
-- issuing Admin also invalidates it. Lock prevents concurrent scrape stampedes.
|
||||
select s.* into settings from public.prometheus_settings s
|
||||
where s.id and s.token_hash=p_token_hash
|
||||
and exists(select 1 from public.profiles p where p.id=s.updated_by and p.role='admin')
|
||||
for update;
|
||||
if not found then return null; end if;
|
||||
if settings.snapshot_at > collected_at - interval '30 seconds' then
|
||||
update public.prometheus_settings set last_scraped_at=collected_at where id;
|
||||
return settings.snapshot;
|
||||
end if;
|
||||
with samples(metric, labels, value) as (
|
||||
select 'minegeo_accounts', '{}'::jsonb, count(*)::double precision from public.profiles
|
||||
union all select 'minegeo_published_panoramas', '{}'::jsonb, coalesce(sum(panorama_count),0)::double precision from public.datasets where state='published'
|
||||
union all select 'minegeo_games_started_24h', '{}'::jsonb, count(*)::double precision from public.game_sessions where created_at >= collected_at - interval '24 hours'
|
||||
union all select 'minegeo_rooms_created_24h', '{}'::jsonb, count(*)::double precision from public.multiplayer_rooms where created_at >= collected_at - interval '24 hours'
|
||||
union all select 'minegeo_snapshot_timestamp_seconds', '{}'::jsonb, extract(epoch from collected_at)::double precision
|
||||
union all
|
||||
select 'minegeo_maps', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
|
||||
from (values('uploading'),('processing'),('draft'),('review'),('published'),('rejected'),('unpublished'),('failed')) v(state)
|
||||
left join (select state,count(*) n from public.datasets group by state) c using(state)
|
||||
union all
|
||||
select 'minegeo_games', jsonb_build_object('difficulty',d.difficulty,'status',v.status), coalesce(c.n,0)::double precision
|
||||
from (values('easy'),('normal'),('hard')) d(difficulty)
|
||||
cross join (values('incomplete'),('complete')) v(status)
|
||||
left join (select difficulty,case when current_round=5 then 'complete' else 'incomplete' end status,count(*) n
|
||||
from public.game_sessions group by 1,2) c using(difficulty,status)
|
||||
union all
|
||||
select 'minegeo_rooms', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
|
||||
from (values('lobby'),('loading'),('playing'),('results'),('finished'),('closed')) v(state)
|
||||
left join (select status state,count(*) n from public.multiplayer_rooms group by status) c using(state)
|
||||
union all
|
||||
select 'minegeo_upload_jobs', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
|
||||
from (values('queued'),('running'),('complete'),('failed'),('cancelled')) v(state)
|
||||
left join (select state,count(*) n from public.upload_jobs group by state) c using(state)
|
||||
union all
|
||||
select 'minegeo_bug_reports', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
|
||||
from (values('draft'),('pending'),('open'),('done')) v(state)
|
||||
left join (select state,count(*) n from public.bug_reports group by state) c using(state)
|
||||
union all
|
||||
select 'minegeo_content_reports', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
|
||||
from (values('open'),('resolved'),('confirmed'),('dismissed')) v(state)
|
||||
left join (select state,count(*) n from public.reports group by state) c using(state)
|
||||
union all
|
||||
select 'minegeo_discord_announcements', jsonb_build_object('state',v.state), coalesce(c.n,0)::double precision
|
||||
from (values('pending'),('sending'),('sent'),('skipped'),('failed')) v(state)
|
||||
left join (select state,count(*) n from public.discord_announcements group by state) c using(state)
|
||||
) select jsonb_agg(jsonb_build_object('metric',metric,'labels',labels,'value',value) order by metric,labels::text) into result from samples;
|
||||
update public.prometheus_settings set last_scraped_at=collected_at, snapshot_at=collected_at, snapshot=result where id;
|
||||
return result;
|
||||
end; $$;
|
||||
|
||||
revoke all on function public.prometheus_status(uuid), public.configure_prometheus(uuid,text), public.read_prometheus_metrics(text) from public,anon,authenticated;
|
||||
grant execute on function public.prometheus_status(uuid), public.configure_prometheus(uuid,text), public.read_prometheus_metrics(text) to service_role;
|
||||
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