@guard-rail/core 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +168 -36
- package/dist/adapters/supabase/auth.js +1 -2
- package/dist/adapters/supabase/auth.js.map +1 -1
- package/dist/adapters/supabase/client.js +6 -1
- package/dist/adapters/supabase/client.js.map +1 -1
- package/dist/adapters/supabase/confirm.d.ts +11 -11
- package/dist/adapters/supabase/confirm.js +14 -18
- package/dist/adapters/supabase/confirm.js.map +1 -1
- package/dist/adapters/supabase/index.d.ts +3 -1
- package/dist/adapters/supabase/index.js +5 -0
- package/dist/adapters/supabase/index.js.map +1 -1
- package/dist/adapters/supabase/rateLimit.d.ts +24 -0
- package/dist/adapters/supabase/rateLimit.js +37 -0
- package/dist/adapters/supabase/rateLimit.js.map +1 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +911 -0
- package/dist/cli/doctor.js.map +1 -0
- package/dist/cli/init-db.js +23 -5
- package/dist/cli/init-db.js.map +1 -1
- package/dist/core/adapter-types.d.ts +71 -1
- package/dist/core/config.d.ts +12 -1
- package/dist/core/config.js +29 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/validate.d.ts +47 -0
- package/dist/core/validate.js +118 -0
- package/dist/core/validate.js.map +1 -0
- package/dist/index.client.d.ts +1 -1
- package/dist/index.client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/react/AuthRedirectSync.js +28 -22
- package/dist/react/AuthRedirectSync.js.map +1 -1
- package/dist/react/GRail/render.js +3 -2
- package/dist/react/GRail/render.js.map +1 -1
- package/dist/server/actions.js +52 -31
- package/dist/server/actions.js.map +1 -1
- package/dist/server/loader.js +4 -4
- package/dist/server/loader.js.map +1 -1
- package/dist/server/rateLimit.d.ts +21 -0
- package/dist/server/rateLimit.js +94 -0
- package/dist/server/rateLimit.js.map +1 -0
- package/dist/server/repeaterActions.js +9 -1
- package/dist/server/repeaterActions.js.map +1 -1
- package/dist/server/wrapper.js +18 -1
- package/dist/server/wrapper.js.map +1 -1
- package/package.json +14 -13
- package/supabase/admin/check-grants.sql +41 -0
- package/supabase/migrations/20261028000000_require_confirmed_email.sql +53 -0
- package/supabase/migrations/20261028010000_rate_limits.sql +67 -0
- package/supabase/migrations/20261028020000_member_and_bucket_guards.sql +96 -0
- package/scripts/postinstall.mjs +0 -48
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
-- Identity by confirmed email only.
|
|
2
|
+
--
|
|
3
|
+
-- The init migration matched an invitation to a person by the `email` claim in
|
|
4
|
+
-- their JWT. That claim is only as trustworthy as the project's signup
|
|
5
|
+
-- settings: where an address can be registered without being confirmed, anyone
|
|
6
|
+
-- could sign up as an invited admin's address and claim the invitation (or read
|
|
7
|
+
-- the roster row addressed to it). This reads the address from auth.users
|
|
8
|
+
-- instead, and only once Supabase has confirmed the person controls it.
|
|
9
|
+
--
|
|
10
|
+
-- Safe to re-run; changes no data.
|
|
11
|
+
|
|
12
|
+
create or replace function guardrail.gr_confirmed_email()
|
|
13
|
+
returns text
|
|
14
|
+
language sql
|
|
15
|
+
stable
|
|
16
|
+
security definer
|
|
17
|
+
set search_path = pg_temp
|
|
18
|
+
as $$
|
|
19
|
+
select lower(u.email)
|
|
20
|
+
from auth.users u
|
|
21
|
+
where u.id = auth.uid()
|
|
22
|
+
and u.email_confirmed_at is not null;
|
|
23
|
+
$$;
|
|
24
|
+
|
|
25
|
+
revoke all on function guardrail.gr_confirmed_email() from public;
|
|
26
|
+
grant execute on function guardrail.gr_confirmed_email() to authenticated;
|
|
27
|
+
|
|
28
|
+
drop policy if exists "members read their own site roster" on guardrail.gr_members;
|
|
29
|
+
create policy "members read their own site roster"
|
|
30
|
+
on guardrail.gr_members for select
|
|
31
|
+
to authenticated
|
|
32
|
+
using (
|
|
33
|
+
user_id = auth.uid()
|
|
34
|
+
or lower(email) = guardrail.gr_confirmed_email()
|
|
35
|
+
or guardrail.gr_has_role(site_id, array['admin', 'editor'])
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
create or replace function guardrail.gr_claim_invite(target_site text)
|
|
39
|
+
returns void
|
|
40
|
+
language sql
|
|
41
|
+
security definer
|
|
42
|
+
set search_path = guardrail, pg_temp
|
|
43
|
+
as $$
|
|
44
|
+
update guardrail.gr_members
|
|
45
|
+
set user_id = auth.uid(),
|
|
46
|
+
status = 'active'
|
|
47
|
+
where site_id = target_site
|
|
48
|
+
and lower(email) = guardrail.gr_confirmed_email()
|
|
49
|
+
and status = 'pending';
|
|
50
|
+
$$;
|
|
51
|
+
|
|
52
|
+
revoke all on function guardrail.gr_claim_invite(text) from public;
|
|
53
|
+
grant execute on function guardrail.gr_claim_invite(text) to authenticated;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
-- Rate-limit counters, for the Supabase rate limiter that ships with
|
|
2
|
+
-- @guard-rail/core (used automatically when the adapter has a service-role key).
|
|
3
|
+
--
|
|
4
|
+
-- One row per (limit, caller) bucket, fixed window. gr_rate_hit() counts an
|
|
5
|
+
-- attempt and says whether it is still within the limit, in a single atomic
|
|
6
|
+
-- statement, so concurrent requests can't both read "under the limit".
|
|
7
|
+
--
|
|
8
|
+
-- Callable by service_role only. It has to be: sign-in and "Forgot password?"
|
|
9
|
+
-- happen with no session, and handing anyone (anon included) the ability to
|
|
10
|
+
-- bump arbitrary counters would let them lock other people out. The table
|
|
11
|
+
-- itself is unreachable through the Data API.
|
|
12
|
+
|
|
13
|
+
create table if not exists guardrail.gr_rate_limits (
|
|
14
|
+
bucket text primary key,
|
|
15
|
+
window_start timestamptz not null default now(),
|
|
16
|
+
expires_at timestamptz not null,
|
|
17
|
+
hits integer not null default 0
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
alter table guardrail.gr_rate_limits enable row level security;
|
|
21
|
+
-- No policies on purpose: no role but the definer function (and service_role,
|
|
22
|
+
-- which bypasses RLS) can read or write it.
|
|
23
|
+
revoke all on guardrail.gr_rate_limits from public, anon, authenticated;
|
|
24
|
+
grant select, insert, update, delete on guardrail.gr_rate_limits to service_role;
|
|
25
|
+
|
|
26
|
+
create index if not exists gr_rate_limits_expires_idx on guardrail.gr_rate_limits (expires_at);
|
|
27
|
+
|
|
28
|
+
create or replace function guardrail.gr_rate_hit(
|
|
29
|
+
p_name text,
|
|
30
|
+
p_key text,
|
|
31
|
+
p_limit integer,
|
|
32
|
+
p_window_seconds integer
|
|
33
|
+
)
|
|
34
|
+
returns table (allowed boolean, retry_after_seconds integer)
|
|
35
|
+
language plpgsql
|
|
36
|
+
security definer
|
|
37
|
+
set search_path = guardrail, pg_temp
|
|
38
|
+
as $$
|
|
39
|
+
declare
|
|
40
|
+
v_bucket text := p_name || '|' || p_key;
|
|
41
|
+
v_window interval := make_interval(secs => p_window_seconds);
|
|
42
|
+
v_hits integer;
|
|
43
|
+
v_expires timestamptz;
|
|
44
|
+
begin
|
|
45
|
+
-- Start a fresh window if the old one has run out; otherwise count into it.
|
|
46
|
+
insert into guardrail.gr_rate_limits as t (bucket, window_start, expires_at, hits)
|
|
47
|
+
values (v_bucket, now(), now() + v_window, 1)
|
|
48
|
+
on conflict (bucket) do update
|
|
49
|
+
set window_start = case when t.expires_at <= now() then now() else t.window_start end,
|
|
50
|
+
expires_at = case when t.expires_at <= now() then now() + v_window else t.expires_at end,
|
|
51
|
+
hits = case when t.expires_at <= now() then 1 else t.hits + 1 end
|
|
52
|
+
returning t.hits, t.expires_at into v_hits, v_expires;
|
|
53
|
+
|
|
54
|
+
-- Housekeeping, on roughly 1 call in 50, so the table doesn't grow forever.
|
|
55
|
+
if random() < 0.02 then
|
|
56
|
+
delete from guardrail.gr_rate_limits where expires_at < now() - interval '1 hour';
|
|
57
|
+
end if;
|
|
58
|
+
|
|
59
|
+
return query select
|
|
60
|
+
v_hits <= p_limit,
|
|
61
|
+
case when v_hits <= p_limit then 0
|
|
62
|
+
else greatest(1, ceil(extract(epoch from (v_expires - now())))::integer) end;
|
|
63
|
+
end;
|
|
64
|
+
$$;
|
|
65
|
+
|
|
66
|
+
revoke all on function guardrail.gr_rate_hit(text, text, integer, integer) from public, anon, authenticated;
|
|
67
|
+
grant execute on function guardrail.gr_rate_hit(text, text, integer, integer) to service_role;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
-- Two guards that used to live only in application code.
|
|
2
|
+
--
|
|
3
|
+
-- Safe to re-run; changes no data.
|
|
4
|
+
|
|
5
|
+
/* ------------------------------------------------------ last admin --- */
|
|
6
|
+
-- "A site must keep at least one admin" was enforced by the Server Action, but
|
|
7
|
+
-- an admin talking to the Data API directly could delete or demote the last one
|
|
8
|
+
-- and leave the site with nobody able to manage it. This enforces it where the
|
|
9
|
+
-- data is. It applies to signed-in users only: service_role, the SQL editor and
|
|
10
|
+
-- migrations (no auth.uid()) can still do whatever maintenance they need.
|
|
11
|
+
create or replace function guardrail.gr_keep_one_admin()
|
|
12
|
+
returns trigger
|
|
13
|
+
language plpgsql
|
|
14
|
+
security definer
|
|
15
|
+
set search_path = guardrail, pg_temp
|
|
16
|
+
as $$
|
|
17
|
+
begin
|
|
18
|
+
if auth.uid() is null then
|
|
19
|
+
return coalesce(new, old);
|
|
20
|
+
end if;
|
|
21
|
+
|
|
22
|
+
if old.role = 'admin' and old.status = 'active'
|
|
23
|
+
and (
|
|
24
|
+
tg_op = 'DELETE'
|
|
25
|
+
or new.role <> 'admin'
|
|
26
|
+
or new.status <> 'active'
|
|
27
|
+
or new.site_id is distinct from old.site_id
|
|
28
|
+
)
|
|
29
|
+
and not exists (
|
|
30
|
+
select 1
|
|
31
|
+
from guardrail.gr_members m
|
|
32
|
+
where m.site_id = old.site_id
|
|
33
|
+
and m.role = 'admin'
|
|
34
|
+
and m.status = 'active'
|
|
35
|
+
and m.email <> old.email
|
|
36
|
+
)
|
|
37
|
+
then
|
|
38
|
+
raise exception 'A site must keep at least one active admin.'
|
|
39
|
+
using errcode = '23514';
|
|
40
|
+
end if;
|
|
41
|
+
|
|
42
|
+
return coalesce(new, old);
|
|
43
|
+
end;
|
|
44
|
+
$$;
|
|
45
|
+
|
|
46
|
+
revoke all on function guardrail.gr_keep_one_admin() from public;
|
|
47
|
+
|
|
48
|
+
drop trigger if exists gr_keep_one_admin on guardrail.gr_members;
|
|
49
|
+
create trigger gr_keep_one_admin
|
|
50
|
+
before update or delete on guardrail.gr_members
|
|
51
|
+
for each row execute function guardrail.gr_keep_one_admin();
|
|
52
|
+
|
|
53
|
+
/* ------------------------------------------------------ bucket cap --- */
|
|
54
|
+
-- Any editor can call gr_ensure_bucket(), which meant they could create 'gr-'
|
|
55
|
+
-- buckets without limit. Existing buckets are untouched and still resolve; only
|
|
56
|
+
-- creating a new one past the cap is refused.
|
|
57
|
+
create or replace function guardrail.gr_ensure_bucket(target_bucket text)
|
|
58
|
+
returns void
|
|
59
|
+
language plpgsql
|
|
60
|
+
security definer
|
|
61
|
+
set search_path = guardrail, pg_temp
|
|
62
|
+
as $$
|
|
63
|
+
begin
|
|
64
|
+
if target_bucket !~ '^gr-[a-z0-9][a-z0-9-]{0,50}$' then
|
|
65
|
+
raise exception 'Guardrail bucket names must start with gr- and use a-z, 0-9, hyphens: %',
|
|
66
|
+
target_bucket;
|
|
67
|
+
end if;
|
|
68
|
+
|
|
69
|
+
if not exists (
|
|
70
|
+
select 1 from guardrail.gr_members
|
|
71
|
+
where user_id = auth.uid()
|
|
72
|
+
and status = 'active'
|
|
73
|
+
and role in ('admin', 'editor')
|
|
74
|
+
) then
|
|
75
|
+
raise exception 'Only Guardrail editors can create media buckets';
|
|
76
|
+
end if;
|
|
77
|
+
|
|
78
|
+
if not exists (select 1 from storage.buckets where id = target_bucket)
|
|
79
|
+
and (select count(*) from storage.buckets where id like 'gr-%') >= 50 then
|
|
80
|
+
raise exception 'This project already has the maximum number of Guardrail media buckets (50).';
|
|
81
|
+
end if;
|
|
82
|
+
|
|
83
|
+
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
|
|
84
|
+
values (
|
|
85
|
+
target_bucket,
|
|
86
|
+
target_bucket,
|
|
87
|
+
true,
|
|
88
|
+
10485760,
|
|
89
|
+
array['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/avif']
|
|
90
|
+
)
|
|
91
|
+
on conflict (id) do nothing;
|
|
92
|
+
end;
|
|
93
|
+
$$;
|
|
94
|
+
|
|
95
|
+
revoke all on function guardrail.gr_ensure_bucket(text) from public;
|
|
96
|
+
grant execute on function guardrail.gr_ensure_bucket(text) to authenticated;
|
package/scripts/postinstall.mjs
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
// Plain, already-final JS — deliberately NOT part of the tsup build. A
|
|
2
|
-
// postinstall script has to exist the instant npm/pnpm/yarn extracts the
|
|
3
|
-
// package, with no dependency on a build step having run yet, which would
|
|
4
|
-
// otherwise race tsup during this very monorepo's own installs (lifecycle
|
|
5
|
-
// script ordering between "postinstall" and "prepare" isn't a guarantee
|
|
6
|
-
// worth relying on).
|
|
7
|
-
//
|
|
8
|
-
// Runs after `npm install @guard-rail/core` in a consuming project — just
|
|
9
|
-
// onboarding guidance, easy to miss otherwise among the rest of an install's
|
|
10
|
-
// output.
|
|
11
|
-
//
|
|
12
|
-
// Deliberately silent in two cases:
|
|
13
|
-
// - CI: this is guidance for a human at a keyboard, not something that
|
|
14
|
-
// belongs in build logs.
|
|
15
|
-
// - Running inside this very monorepo: pnpm runs a workspace package's own
|
|
16
|
-
// lifecycle scripts too (already visible via the "prepare" build step on
|
|
17
|
-
// every `pnpm install`), which would otherwise print this on every
|
|
18
|
-
// contributor's install of this repo, not just a real consumer's. Resolved
|
|
19
|
-
// through the pnpm workspace symlink, this file's own real path sits under
|
|
20
|
-
// packages/guardrail/ — never inside a node_modules/ — which a real
|
|
21
|
-
// consumer's install always does.
|
|
22
|
-
import { fileURLToPath } from "node:url";
|
|
23
|
-
|
|
24
|
-
const isRealInstall = fileURLToPath(import.meta.url).includes("node_modules");
|
|
25
|
-
|
|
26
|
-
if (isRealInstall && !process.env.CI) {
|
|
27
|
-
console.log(`
|
|
28
|
-
Guardrail installed. Next steps:
|
|
29
|
-
|
|
30
|
-
1. Set up the database (skip both if this Supabase project already has
|
|
31
|
-
Guardrail applied for another site — see "Many sites, one Supabase
|
|
32
|
-
project" in the README):
|
|
33
|
-
- pnpm exec guardrail-init-db --push
|
|
34
|
-
- If that project is hosted (not local Docker), also add "guardrail"
|
|
35
|
-
under Settings > API > Exposed schemas in the Supabase dashboard —
|
|
36
|
-
config.toml only covers local dev.
|
|
37
|
-
|
|
38
|
-
2. Wrap your root layout with withGuardrail and mark fields with <GRail>.
|
|
39
|
-
Full guide: https://www.npmjs.com/package/@guard-rail/core
|
|
40
|
-
|
|
41
|
-
3. Once SUPABASE_URL / SUPABASE_ANON_KEY are set, seed yourself as the
|
|
42
|
-
first admin on a site (needs the service_role key, not the anon key —
|
|
43
|
-
Settings > API in the dashboard; pass it inline, don't store it):
|
|
44
|
-
|
|
45
|
-
SERVICE_KEY=... pnpm exec guardrail-seed-user \\
|
|
46
|
-
--email=you@example.com --password=... --site-id=my-site
|
|
47
|
-
`);
|
|
48
|
-
}
|