@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.
Files changed (51) hide show
  1. package/README.md +168 -36
  2. package/dist/adapters/supabase/auth.js +1 -2
  3. package/dist/adapters/supabase/auth.js.map +1 -1
  4. package/dist/adapters/supabase/client.js +6 -1
  5. package/dist/adapters/supabase/client.js.map +1 -1
  6. package/dist/adapters/supabase/confirm.d.ts +11 -11
  7. package/dist/adapters/supabase/confirm.js +14 -18
  8. package/dist/adapters/supabase/confirm.js.map +1 -1
  9. package/dist/adapters/supabase/index.d.ts +3 -1
  10. package/dist/adapters/supabase/index.js +5 -0
  11. package/dist/adapters/supabase/index.js.map +1 -1
  12. package/dist/adapters/supabase/rateLimit.d.ts +24 -0
  13. package/dist/adapters/supabase/rateLimit.js +37 -0
  14. package/dist/adapters/supabase/rateLimit.js.map +1 -0
  15. package/dist/cli/doctor.d.ts +1 -0
  16. package/dist/cli/doctor.js +911 -0
  17. package/dist/cli/doctor.js.map +1 -0
  18. package/dist/cli/init-db.js +23 -5
  19. package/dist/cli/init-db.js.map +1 -1
  20. package/dist/core/adapter-types.d.ts +71 -1
  21. package/dist/core/config.d.ts +12 -1
  22. package/dist/core/config.js +29 -1
  23. package/dist/core/config.js.map +1 -1
  24. package/dist/core/validate.d.ts +47 -0
  25. package/dist/core/validate.js +118 -0
  26. package/dist/core/validate.js.map +1 -0
  27. package/dist/index.client.d.ts +1 -1
  28. package/dist/index.client.js.map +1 -1
  29. package/dist/index.d.ts +1 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/react/AuthRedirectSync.js +28 -22
  32. package/dist/react/AuthRedirectSync.js.map +1 -1
  33. package/dist/react/GRail/render.js +3 -2
  34. package/dist/react/GRail/render.js.map +1 -1
  35. package/dist/server/actions.js +52 -31
  36. package/dist/server/actions.js.map +1 -1
  37. package/dist/server/loader.js +4 -4
  38. package/dist/server/loader.js.map +1 -1
  39. package/dist/server/rateLimit.d.ts +21 -0
  40. package/dist/server/rateLimit.js +94 -0
  41. package/dist/server/rateLimit.js.map +1 -0
  42. package/dist/server/repeaterActions.js +9 -1
  43. package/dist/server/repeaterActions.js.map +1 -1
  44. package/dist/server/wrapper.js +18 -1
  45. package/dist/server/wrapper.js.map +1 -1
  46. package/package.json +14 -13
  47. package/supabase/admin/check-grants.sql +41 -0
  48. package/supabase/migrations/20261028000000_require_confirmed_email.sql +53 -0
  49. package/supabase/migrations/20261028010000_rate_limits.sql +67 -0
  50. package/supabase/migrations/20261028020000_member_and_bucket_guards.sql +96 -0
  51. 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;
@@ -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
- }