@guard-rail/core 0.9.1 → 0.11.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 (74) hide show
  1. package/README.md +253 -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 +1046 -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/csp.d.ts +32 -0
  25. package/dist/core/csp.js +26 -0
  26. package/dist/core/csp.js.map +1 -0
  27. package/dist/core/validate.d.ts +47 -0
  28. package/dist/core/validate.js +118 -0
  29. package/dist/core/validate.js.map +1 -0
  30. package/dist/index.client.d.ts +2 -1
  31. package/dist/index.client.js +3 -0
  32. package/dist/index.client.js.map +1 -1
  33. package/dist/index.d.ts +2 -1
  34. package/dist/index.js +3 -0
  35. package/dist/index.js.map +1 -1
  36. package/dist/react/Admin.d.ts +5 -3
  37. package/dist/react/Admin.js +29 -41
  38. package/dist/react/Admin.js.map +1 -1
  39. package/dist/react/AuthDrawer.js +22 -34
  40. package/dist/react/AuthDrawer.js.map +1 -1
  41. package/dist/react/AuthRedirectSync.js +28 -22
  42. package/dist/react/AuthRedirectSync.js.map +1 -1
  43. package/dist/react/GRail/client.js +27 -47
  44. package/dist/react/GRail/client.js.map +1 -1
  45. package/dist/react/GRail/render.js +3 -2
  46. package/dist/react/GRail/render.js.map +1 -1
  47. package/dist/react/MediaPicker.js +12 -16
  48. package/dist/react/MediaPicker.js.map +1 -1
  49. package/dist/react/Repeater/client.js +12 -13
  50. package/dist/react/Repeater/client.js.map +1 -1
  51. package/dist/react/Team.d.ts +1 -7
  52. package/dist/react/Team.js +21 -46
  53. package/dist/react/Team.js.map +1 -1
  54. package/dist/react/guardrail.css +563 -0
  55. package/dist/server/actions.js +52 -31
  56. package/dist/server/actions.js.map +1 -1
  57. package/dist/server/loader.js +4 -4
  58. package/dist/server/loader.js.map +1 -1
  59. package/dist/server/rateLimit.d.ts +21 -0
  60. package/dist/server/rateLimit.js +94 -0
  61. package/dist/server/rateLimit.js.map +1 -0
  62. package/dist/server/repeaterActions.js +9 -1
  63. package/dist/server/repeaterActions.js.map +1 -1
  64. package/dist/server/wrapper.js +18 -1
  65. package/dist/server/wrapper.js.map +1 -1
  66. package/package.json +11 -9
  67. package/supabase/admin/check-grants.sql +41 -0
  68. package/supabase/migrations/20261028000000_require_confirmed_email.sql +53 -0
  69. package/supabase/migrations/20261028010000_rate_limits.sql +67 -0
  70. package/supabase/migrations/20261028020000_member_and_bucket_guards.sql +96 -0
  71. package/dist/react/styles.d.ts +0 -504
  72. package/dist/react/styles.js +0 -500
  73. package/dist/react/styles.js.map +0 -1
  74. package/scripts/postinstall.mjs +0 -48
package/README.md CHANGED
@@ -50,36 +50,8 @@ SUPABASE_ANON_KEY=...
50
50
  GUARDRAIL_SITE_ID=my-site # optional; defaults to "default"
51
51
  ```
52
52
 
53
- ### Many sites, one Supabase project
54
-
55
- Every table above is keyed by `site_id`, and every RLS policy checks a role
56
- *for that specific site_id* — this isn't a convenience layer on top of
57
- separate installs, it's how the schema is built. A migration, its Exposed
58
- Schemas setting, and the default storage bucket are one-time, per-*project*
59
- setup, not per-site.
60
-
61
- So running several sites against a single Supabase project is a supported,
62
- intended pattern, not a workaround — useful while a site is small, in
63
- testing, or just doesn't warrant its own project yet. For the second and
64
- later sites on an already-set-up project:
65
-
66
- 1. Install the package as usual. Skip the migration and the Exposed Schemas
67
- step entirely — both already apply to every site on that project.
68
- 2. Point `SUPABASE_URL` / `SUPABASE_ANON_KEY` at the same project, and set a
69
- new, unique `GUARDRAIL_SITE_ID`.
70
- 3. Seed a bootstrap admin row for that new `site_id` — membership is also
71
- per-site, so an existing admin on one site has no role on another until
72
- added there specifically.
73
-
74
- **One tradeoff worth knowing:** `gr_content` and `gr_repeater_items` are
75
- readable by anyone (`using (true)`), not scoped by `site_id` — necessary
76
- since anonymous visitors must be able to read a site's content to render it,
77
- and there's no draft/unpublished state to protect in the first place. But it
78
- does mean any one site's anon key can read every other site's content rows
79
- directly through the REST API, not just through its own rendered pages. Low
80
- stakes for content that's already meant to be public on its own site, but
81
- worth factoring in before treating a shared project as a hard tenant
82
- boundary.
53
+ Then run `pnpm exec guardrail-doctor` to confirm everything is wired up (see
54
+ "Checking your setup" below).
83
55
 
84
56
  ## Usage
85
57
 
@@ -250,15 +222,21 @@ so there is no Site URL, redirect allowlist, or email template to configure.
250
222
 
251
223
  Worth knowing:
252
224
 
225
+ - **Set `GUARDRAIL_SITE_URL` in production** (or `siteUrl` in `withGuardrail()`) to
226
+ your site's public address, e.g. `https://mysite.com`. Every emailed link is
227
+ built from it. Guardrail never builds a link from the incoming request's
228
+ `Host`/`Origin` — "Forgot password?" is open to anyone, so a forged header
229
+ would mail a working reset link to someone else's domain. Without it,
230
+ production refuses to send invites and resets and tells the admin why. (In
231
+ development the address you're browsing from is used.)
253
232
  - **Setting a password can't be skipped.** It's the only way back in once a
254
233
  session ends, so the drawer has no dismiss button — only "Cancel and sign out".
255
234
  - **Links work once.** A link that has expired or been used shows "That link
256
235
  didn't work" with a form to request a new one, rather than doing nothing.
257
- - **People who already have an account never see the set-password step.** Auth
258
- users are project-wide, so on a Supabase project shared by several sites
259
- someone has one password everywhere, and a second prompt would have them
260
- overwrite it. They're told they've been added and sign in as usual; if they've
261
- forgotten it, **Forgot password?** covers that.
236
+ - **People who already have an account never see the set-password step.** If the
237
+ address you invite already has a Supabase account, a second prompt would have
238
+ them overwrite the password they use. They're told they've been added and sign
239
+ in as usual; if they've forgotten it, **Forgot password?** covers that.
262
240
  - **"Forgot password?" answers the same whether or not the address belongs to
263
241
  anyone**, takes the same time, and only sends to members — never to a stranger.
264
242
  It sends at most one email per address per minute.
@@ -361,7 +339,7 @@ the file after resizing.
361
339
  1. The init migration creates the default `gr-media` bucket and storage
362
340
  policies scoped to the `gr-` prefix. To use a different bucket, just pass
363
341
  its name: any `gr-*` bucket is created automatically on the first upload,
364
- no SQL edits needed.
342
+ no SQL edits needed (up to 50 per project).
365
343
  2. Allow the storage host in `next.config.ts`, or `next/image` will throw:
366
344
  ```ts
367
345
  images: {
@@ -518,6 +496,54 @@ the outline ignores the pointer so your overlaid content stays fully clickable,
518
496
  and the handle is the only thing that opens the editor (it also won't trigger a
519
497
  surrounding `<Link>`).
520
498
 
499
+ ### Theming
500
+
501
+ The chrome is a real stylesheet (`guardrail.css`) that the package imports itself —
502
+ there is nothing to link by hand, and no inline styles, so it works under a strict
503
+ Content-Security-Policy. Every color is a CSS variable, so restyling it is a plain
504
+ `:root` rule in your own CSS:
505
+
506
+ ```css
507
+ :root {
508
+ --gr-accent: #7c3aed;
509
+ --gr-field-outline: #7c3aed;
510
+ --gr-overlay-bg: #1e1b4b;
511
+ --gr-overlay-fg: #e0e7ff;
512
+ }
513
+ ```
514
+
515
+ No `!important` and no selector tricks: Guardrail's defaults are declared with zero
516
+ specificity, so your `:root` rule wins whichever stylesheet loads first.
517
+
518
+ There are two layers. Change a **base** color to retheme everything, or set an
519
+ **overlay** color to restyle only the floating surfaces (the sign-in bar, toolbar,
520
+ edit popups, drawers and image picker). The overlay variables default to the base
521
+ ones, so you only set what you want to differ.
522
+
523
+ | Variable | What it colors |
524
+ |---|---|
525
+ | `--gr-overlay-bg` | Background of the sign-in bar, popups, drawers and picker |
526
+ | `--gr-overlay-bg-glass` | Background of the toolbar and the inline editing bar (translucent) |
527
+ | `--gr-overlay-fg` | Text on all of the above |
528
+ | `--gr-overlay-border` | Their borders |
529
+ | `--gr-field-outline` | The dashed outline around an editable field |
530
+ | `--gr-field-outline-active` | The solid outline while a field is being edited |
531
+ | `--gr-accent`, `--gr-accent-soft`, `--gr-accent-text`, `--gr-on-accent` | Highlights, and text on an accent background |
532
+ | `--gr-primary-bg`, `--gr-primary-fg` | Primary buttons |
533
+ | `--gr-danger`, `--gr-label`, `--gr-badge-bg`, `--gr-badge-fg` | Errors, small labels, the "pending" badge |
534
+ | `--gr-scrim`, `--gr-scrim-modal` | The dimmed backdrop behind drawers and the picker |
535
+ | `--gr-bg`, `--gr-bg-glass`, `--gr-fg`, `--gr-fg2`, `--gr-muted`, `--gr-subtle`, `--gr-border`, `--gr-border-strong`, `--gr-border-soft`, `--gr-input-bg`, `--gr-soft`, `--gr-shadow` | The base palette the overlay variables fall back to, plus inputs and secondary text |
536
+
537
+ Set any of them inside `@media (prefers-color-scheme: dark)` if you want a
538
+ different value in dark mode — the chrome follows the visitor's OS setting. The
539
+ outline around an editable text field is `[data-gr-field="text"]` (and
540
+ `[data-gr-editing]` while it's being edited), so you can restyle or hide it with a
541
+ rule of your own; to hide it, set `--gr-field-outline: transparent`. Every class
542
+ Guardrail adds is prefixed `gr-`.
543
+
544
+ If your setup doesn't bundle CSS imported from a package, import it yourself once,
545
+ in your root layout: `import "@guard-rail/core/styles.css";`.
546
+
521
547
  ## Extending the toolbar
522
548
 
523
549
  The bundled toolbar (edit indicator, Team, Exit edit mode) has one extension point:
@@ -611,8 +637,199 @@ Anything outside the preload (a `prefetch.prefix` scope, or a site past the row
611
637
  ceiling) falls back to a batched lookup by key. Set `GUARDRAIL_DEBUG=1` to log
612
638
  what each query resolved.
613
639
 
640
+ ## Checking your setup: `guardrail-doctor`
641
+
642
+ `guardrail-doctor` ships inside this package, so it always matches the version
643
+ you have installed. Run it after installing, after every upgrade, and before you
644
+ deploy:
645
+
646
+ ```bash
647
+ pnpm exec guardrail-doctor
648
+ ```
649
+
650
+ By default it only reads. It checks:
651
+
652
+ - **Project:** the installed versions of `next`, `@supabase/supabase-js`,
653
+ `@supabase/ssr` and `@guard-rail/core` against the npm registry's published
654
+ security advisories (always current — nothing to keep up to date), and that
655
+ the Supabase libraries are installed.
656
+ - **Environment:** the Supabase URL and keys (including keys swapped into each
657
+ other's slots, and secrets behind a `NEXT_PUBLIC_` name), `GUARDRAIL_SITE_URL`,
658
+ email settings, and that your env files are gitignored.
659
+ - **Files:** that every migration this version ships is in
660
+ `supabase/migrations/`, that `next.config` sets anti-framing headers, and that
661
+ the root layout uses `withGuardrail()`.
662
+ - **Database:** that the `guardrail` schema is exposed, the tables and functions
663
+ exist, rate limiting works, and what your project's sign-up settings are
664
+ (whether **Confirm email** is on).
665
+
666
+ | Flag | What it adds |
667
+ |---|---|
668
+ | `--fix` | Repairs what can be repaired in local files: copies missing migrations, adds a `GUARDRAIL_SITE_URL` note to `.env.example` / `.env.local`, adds baseline security headers to `next.config`. It never changes secrets or Supabase dashboard settings, and re-running it is safe. Add `--push` to also run `supabase db push`. |
669
+ | `--app-url=http://localhost:3000` | Also checks the response headers of a running copy of your site, including that Guardrail's stylesheet reached the page and that its Content-Security-Policy lets Guardrail's uploads and images through. |
670
+ | `--e2e` | Runs an end-to-end suite against the database. It creates a throwaway user, a few rows and a test image, then exercises sign-in, claiming an invitation, row-level security (an editor can write their own site and nobody else's; visitors can read but not write), the last-admin guard, rate limiting and image uploads, and deletes everything it made. It asks before touching a database that isn't local (`--yes` skips the question), and needs the service-role key. |
671
+ | `--csp` | Prints a starter `Content-Security-Policy-Report-Only` header built from your `SUPABASE_URL` and exits. |
672
+ | `--offline` | Skips the advisory lookup (the only network call besides your own Supabase project). Package names and versions are what's sent otherwise, as with `pnpm audit`; offline, Next.js is checked against a built-in list that may be out of date. |
673
+ | `--production` | Treats production-only requirements as failures instead of warnings. |
674
+ | `--strict` / `--json` | Exit non-zero on warnings too; machine-readable output for CI. |
675
+
676
+ It exits non-zero when anything fails, so it works in CI. It reads
677
+ `SUPABASE_URL`, `SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_KEY` (or
678
+ `SERVICE_KEY`) from the environment, then `.env.local` and `.env`.
679
+
680
+ ### Upgrading
681
+
682
+ After `pnpm up @guard-rail/core`:
683
+
684
+ ```bash
685
+ pnpm exec guardrail-doctor --fix --push # copy + apply new migrations, patch local files
686
+ pnpm exec guardrail-doctor # see what's left for you to do
687
+ ```
688
+
689
+ What `--fix` can't do for you: set the value of `GUARDRAIL_SITE_URL`, turn on
690
+ **Confirm email** in the Supabase dashboard, and merge headers into a
691
+ `next.config` that already defines its own `headers()`. The doctor says which of
692
+ these apply.
693
+
694
+ ## Before you deploy
695
+
696
+ Run `pnpm exec guardrail-doctor --production` to check everything below.
697
+
698
+ - **Run a patched Next.js.** Guardrail requires `next` 15.5.28 or later, or
699
+ 16.4.1 or later — older releases have published security advisories (including
700
+ remote code execution and middleware bypass). Keep it current;
701
+ `guardrail-doctor` flags a vulnerable version.
702
+ - **Keep email confirmation on** in Supabase (**Authentication > Sign In /
703
+ Providers > Email > Confirm email**), or turn public sign-ups off. This setting
704
+ is what stops someone registering an invited person's address before they do
705
+ and claiming the invitation; with it off, Supabase marks any address confirmed
706
+ the moment it signs up. The `20261028000000_require_confirmed_email` migration
707
+ adds a database-level check that only a confirmed address can claim an
708
+ invitation or read its roster row — defense in depth, not a substitute. Re-run
709
+ `pnpm exec guardrail-init-db --push` after upgrading.
710
+ - **Set `GUARDRAIL_SITE_URL`** (see "Passwords, invites and resets").
711
+ - **Rate limiting.** Sign-in (per account from one address, per account overall,
712
+ and per address — so one person hammering an account can't lock its owner out),
713
+ "Forgot password?" (per address and per inbox),
714
+ the emailed-link exchange, and the admin actions that send invites/resets or
715
+ create uploads are limited out of the box. With the Supabase adapter and a
716
+ `serviceRoleKey`, the counters live in your project's `guardrail` schema
717
+ (`gr_rate_limits`, added by the `rate_limits` migration), so limits hold
718
+ across serverless instances with nothing extra to run. Re-run
719
+ `pnpm exec guardrail-init-db --push` after upgrading to add it; until the
720
+ migration is applied, limits fall back to in-memory counting (with a warning
721
+ in the log) rather than blocking sign-in. If the database can't be reached
722
+ for any other reason, the request is refused.
723
+
724
+ The defaults are deliberately generous for normal use: one account may fail 8
725
+ sign-ins per 15 minutes from one address (and 40 from anywhere), one address
726
+ 30, and "Forgot password?" is limited to 5 per inbox per hour. If a team signs in
727
+ from one shared address, raise what you need:
728
+
729
+ ```ts
730
+ withGuardrail(RootLayout, {
731
+ ...supabase,
732
+ rateLimitRules: { "login:pair": { limit: 20 }, "login:ip": { limit: 100 } },
733
+ });
734
+ ```
735
+
736
+ The names are `login:pair`, `login:account`, `login:ip`, `forgot-password:ip`,
737
+ `forgot-password:account`, `auth-link:ip`, `invite:user`,
738
+ `password-reset:user` and `upload:user`; each takes `limit` and/or
739
+ `windowSeconds`. Someone who is throttled can always **Forgot password?** — the
740
+ emailed link signs them in directly.
741
+
742
+ Without a service-role key the built-in counters are in memory only, which
743
+ isn't enough on serverless. Pass your own limiter instead — for example with
744
+ Upstash:
745
+
746
+ ```ts
747
+ import { Ratelimit } from "@upstash/ratelimit";
748
+ import { Redis } from "@upstash/redis";
749
+
750
+ const redis = Redis.fromEnv();
751
+ const limiters = new Map<string, Ratelimit>();
752
+
753
+ withGuardrail(RootLayout, {
754
+ ...supabase,
755
+ rateLimit: async ({ name, key, limit, windowSeconds }) => {
756
+ let rl = limiters.get(name);
757
+ if (!rl) {
758
+ rl = new Ratelimit({
759
+ redis,
760
+ limiter: Ratelimit.fixedWindow(limit, `${windowSeconds} s`),
761
+ prefix: `guardrail:${name}`,
762
+ });
763
+ limiters.set(name, rl);
764
+ }
765
+ const { success, reset } = await rl.limit(key);
766
+ return { allowed: success, retryAfterSeconds: Math.ceil((reset - Date.now()) / 1000) };
767
+ },
768
+ });
769
+ ```
770
+
771
+ Address-keyed limits read `X-Forwarded-For`, which is only trustworthy behind a
772
+ proxy that overwrites it (Vercel, Cloudflare and most load balancers do). On a
773
+ server reached directly, or behind one that only appends to the header, a client
774
+ can write it themselves — pass a `clientIp` function to read the right header
775
+ instead (it receives the request's `Headers`):
776
+
777
+ ```ts
778
+ withGuardrail(RootLayout, {
779
+ ...supabase,
780
+ clientIp: (headers) => headers.get("cf-connecting-ip") ?? undefined,
781
+ });
782
+ ```
783
+
784
+ The per-account limits don't depend on it. Also set a spend cap in your
785
+ Resend dashboard as the backstop.
786
+ - **Add security headers** in `next.config`: at least a `Content-Security-Policy`
787
+ with `frame-ancestors 'self'` (so the editing chrome can't be framed by another
788
+ site), `Strict-Transport-Security`, and `X-Content-Type-Options: nosniff`.
789
+ `guardrail-doctor --fix` adds this baseline. For a policy that also restricts
790
+ scripts and styles, see "Content Security Policy" below.
791
+
792
+ ### Content Security Policy
793
+
794
+ Guardrail works under a strict policy — no `'unsafe-inline'` for styles and no
795
+ nonce. Its chrome is a stylesheet, and it renders no inline scripts and uses no
796
+ `eval`. A strict policy only has to allow the two places the browser talks to
797
+ Supabase directly:
798
+
799
+ | Directive | Allow | Why |
800
+ |---|---|---|
801
+ | `connect-src` | `'self'` and your Supabase origin | image uploads are sent straight from the browser to Supabase storage |
802
+ | `img-src` | `'self'` and your Supabase origin | uploaded images and the media picker's thumbnails load from storage |
803
+ | `style-src` | `'self'` | nothing more |
804
+ | `frame-ancestors` | `'self'` | so the editing chrome can't be framed by another site |
805
+
806
+ Everything else — reads, saves, sign-in — goes through your own server, i.e.
807
+ `'self'`. If you build the header in code, `guardrailCspDirectives()` returns
808
+ these for you to merge into your own:
809
+
810
+ ```ts
811
+ import { guardrailCspDirectives, cspString } from "@guard-rail/core";
812
+
813
+ const gr = guardrailCspDirectives({ supabaseUrl: process.env.SUPABASE_URL });
814
+ // gr["connect-src"] -> ["'self'", "https://abc.supabase.co"]
815
+ // cspString(gr) -> "style-src 'self'; img-src 'self' https://abc.supabase.co; …"
816
+ ```
817
+
818
+ To get started, `pnpm exec guardrail-doctor --csp` prints a starter
819
+ `Content-Security-Policy-Report-Only` header built from your `SUPABASE_URL`: send it
820
+ report-only first, tune it for what your own site loads (analytics, fonts,
821
+ embeds), then enforce it. Once the site is running,
822
+ `pnpm exec guardrail-doctor --app-url=https://your-site.com` checks that the live
823
+ policy lets Guardrail's uploads and images through. A script nonce, if you use
824
+ one, is set by your site (Next.js does it from a proxy); Guardrail doesn't need it.
825
+
614
826
  ## Known limitations
615
827
 
828
+ - **Cache Components isn't supported.** Next's `cacheComponents: true` refuses
829
+ routes that read cookies outside `<Suspense>`, and Guardrail reads the session
830
+ cookie to decide what to render. Leave it off (some newer `create-next-app`
831
+ templates turn it on — remove `cacheComponents` and `partialPrefetching` from
832
+ `next.config`; `guardrail-doctor --fix` does it for you).
616
833
  - `withGuardrail` reads cookies in the root layout, which opts routes into
617
834
  dynamic rendering. See the repo README for the plan.
618
835
  - Saving revalidates the entire layout tree rather than the affected route.
@@ -7,8 +7,7 @@ function isExistingAccountError(error) {
7
7
  }
8
8
  function tokenLink(origin, tokenHash, type) {
9
9
  const link = new URL("/", origin);
10
- link.searchParams.set("token_hash", tokenHash);
11
- link.searchParams.set("type", type);
10
+ link.hash = new URLSearchParams({ token_hash: tokenHash, type }).toString();
12
11
  return link.toString();
13
12
  }
14
13
  function supabaseAuth(creds) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/supabase/auth.ts"],"sourcesContent":["import type { EmailOtpType } from \"@supabase/supabase-js\";\nimport { adminClient, assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport type {\n AuthRedirectParams,\n GRAuthAdapter,\n GRSession,\n} from \"../../core/adapter-types\";\n\n/**\n * Whether an emailed link should land on the Set Password step.\n *\n * Decided by the link's own type, which Guardrail's emails always carry, and\n * by nothing inferred about the account: \"invite\" and \"signup\" are someone's\n * first link into a brand-new account, and \"recovery\" is a password reset. All\n * three exist to let a person choose a password. Anything else — an old magic\n * link, a PKCE code that carries no type — just signs in, as Supabase intends.\n * Passwords are the only credential Guardrail asks people to use; these links\n * exist only to set one.\n */\nexport function needsPasswordFor(type: string | null | undefined): boolean {\n return type === \"invite\" || type === \"signup\" || type === \"recovery\";\n}\n\n/** Supabase's \"this address already has an account\" outcomes, across its error shapes. */\nfunction isExistingAccountError(error: { code?: string; message: string }): boolean {\n return (\n error.code === \"email_exists\" ||\n error.code === \"user_already_exists\" ||\n /already (been )?registered|already exists/i.test(error.message)\n );\n}\n\n/**\n * The link an email carries: the site's own root with the token in the query\n * string, where the always-mounted AuthRedirectSync completes it. Needs no\n * route and no entry in the provider's redirect allowlist (a project-wide\n * setting), and survives an email scanner that pre-fetches links, since nothing\n * is verified until the page's JavaScript runs.\n */\nfunction tokenLink(origin: string, tokenHash: string, type: string): string {\n const link = new URL(\"/\", origin);\n link.searchParams.set(\"token_hash\", tokenHash);\n link.searchParams.set(\"type\", type);\n return link.toString();\n}\n\nexport function supabaseAuth(creds: SupabaseCredentials): GRAuthAdapter {\n assertCredentials(creds);\n\n const adapter: GRAuthAdapter = {\n async authenticate(email, password) {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.signInWithPassword({\n email,\n password,\n });\n\n if (error) return { success: false, error: error.message };\n if (!data.user) return { success: false, error: \"User not found.\" };\n\n return {\n success: true,\n session: { userId: data.user.id, email: data.user.email },\n };\n },\n\n /**\n * Uses getUser(), which revalidates the JWT against the Supabase auth\n * server. getSession() only decodes the cookie, so it would trust a forged\n * token.\n */\n async getSession(): Promise<GRSession | null> {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.getUser();\n if (error || !data.user) return null;\n return { userId: data.user.id, email: data.user.email };\n },\n\n async logout() {\n const client = await sessionClient(creds);\n await client.auth.signOut();\n },\n\n /**\n * Whichever of hash/code/tokenHash is present decides the exchange —\n * these correspond to Supabase's three redirect styles (legacy implicit\n * grant, PKCE, and the token_hash style Guardrail's own confirm route\n * already used), so this handles a link regardless of which one a\n * project's email templates happen to produce, including the\n * unconfigured default (implicit grant, hash-based — what an unedited\n * Supabase project actually sends).\n *\n * Goes through sessionClient(), the same cookie-writing client\n * everything else in this adapter uses — setSession/exchangeCodeForSession/\n * verifyOtp all persist the resulting session through its existing\n * getAll/setAll bridge, so this needs no browser-side Supabase client of\n * its own.\n */\n async completeAuthRedirect({ hash, code, tokenHash, type }: AuthRedirectParams) {\n const client = await sessionClient(creds);\n\n if (hash) {\n const hashParams = new URLSearchParams(hash);\n const access_token = hashParams.get(\"access_token\");\n const refresh_token = hashParams.get(\"refresh_token\");\n const hashType = hashParams.get(\"type\") ?? type;\n if (!access_token || !refresh_token) {\n return { success: false, error: \"Redirect link is missing its tokens.\" };\n }\n const { error } = await client.auth.setSession({ access_token, refresh_token });\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: needsPasswordFor(hashType) };\n }\n\n if (code) {\n const { error } = await client.auth.exchangeCodeForSession(code);\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: needsPasswordFor(type) };\n }\n\n if (tokenHash && type) {\n const { error } = await client.auth.verifyOtp({\n type: type as EmailOtpType,\n token_hash: tokenHash,\n });\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: needsPasswordFor(type) };\n }\n\n return { success: false, error: \"No redirect data to complete.\" };\n },\n\n async setPassword(password: string) {\n const client = await sessionClient(creds);\n const { error } = await client.auth.updateUser({ password });\n return error ? { success: false, error: error.message } : { success: true };\n },\n };\n\n // Only offered when a service-role key was configured: minting a sign-in link\n // server-side is an admin operation, and an adapter without the key simply\n // can't do it, and the admin is told what to add.\n if (creds.serviceRoleKey) {\n /**\n * Creates the account and returns a one-time link, sending nothing — the\n * site's own onInvite handler delivers it (see resendInvite).\n *\n * The link points at the site's own root with the token in the query\n * string, where the always-mounted AuthRedirectSync completes it. That\n * needs no route, no Supabase Site URL / redirect allowlist entry (settings\n * that are project-wide on a shared project), and — unlike a GET handler\n * that verifies the token — survives an email scanner that pre-fetches\n * links, since nothing is verified until the page's JavaScript runs.\n */\n adapter.createInviteLink = async (email, { origin }) => {\n const admin = adminClient(creds);\n\n const res = await admin.auth.admin.generateLink({ type: \"invite\", email });\n if (res.error) {\n // Already has an account — here or on another site on the same project.\n // They have a password, so there's nothing to hand them but the site's\n // address: a token here would be a way to sign in without it, and a\n // set-password prompt would have them overwrite a password they use\n // everywhere. The address opens the sign-in form (?edit=true).\n if (isExistingAccountError(res.error)) {\n return { success: true, link: new URL(\"/?edit=true\", origin).toString(), isNewUser: false };\n }\n return { success: false, error: res.error.message };\n }\n const hashed = res.data?.properties?.hashed_token;\n if (!hashed) return { success: false, error: \"No invite link was returned.\" };\n\n return { success: true, link: tokenLink(origin, hashed, \"invite\"), isNewUser: true };\n };\n\n adapter.createPasswordResetLink = async (email, { origin }) => {\n const res = await adminClient(creds).auth.admin.generateLink({ type: \"recovery\", email });\n const hashed = res.data?.properties?.hashed_token;\n if (res.error || !hashed) {\n return { success: false, error: res.error?.message ?? \"No reset link was returned.\" };\n }\n return { success: true, link: tokenLink(origin, hashed, \"recovery\") };\n };\n }\n\n return adapter;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/adapters/supabase/auth.ts"],"sourcesContent":["import type { EmailOtpType } from \"@supabase/supabase-js\";\nimport { adminClient, assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport type {\n AuthRedirectParams,\n GRAuthAdapter,\n GRSession,\n} from \"../../core/adapter-types\";\n\n/**\n * Whether an emailed link should land on the Set Password step.\n *\n * Decided by the link's own type, which Guardrail's emails always carry, and\n * by nothing inferred about the account: \"invite\" and \"signup\" are someone's\n * first link into a brand-new account, and \"recovery\" is a password reset. All\n * three exist to let a person choose a password. Anything else — an old magic\n * link, a PKCE code that carries no type — just signs in, as Supabase intends.\n * Passwords are the only credential Guardrail asks people to use; these links\n * exist only to set one.\n */\nexport function needsPasswordFor(type: string | null | undefined): boolean {\n return type === \"invite\" || type === \"signup\" || type === \"recovery\";\n}\n\n/** Supabase's \"this address already has an account\" outcomes, across its error shapes. */\nfunction isExistingAccountError(error: { code?: string; message: string }): boolean {\n return (\n error.code === \"email_exists\" ||\n error.code === \"user_already_exists\" ||\n /already (been )?registered|already exists/i.test(error.message)\n );\n}\n\n/**\n * The link an email carries: the site's own root with the token in the URL\n * fragment, where the always-mounted AuthRedirectSync completes it. A fragment\n * is never sent to the server, so the token stays out of access logs, CDN logs\n * and `Referer` headers. Needs no route and no entry in the provider's redirect\n * allowlist (a project-wide setting), and survives an email scanner that\n * pre-fetches links, since nothing is verified until the page's JavaScript runs.\n */\nfunction tokenLink(origin: string, tokenHash: string, type: string): string {\n const link = new URL(\"/\", origin);\n link.hash = new URLSearchParams({ token_hash: tokenHash, type }).toString();\n return link.toString();\n}\n\nexport function supabaseAuth(creds: SupabaseCredentials): GRAuthAdapter {\n assertCredentials(creds);\n\n const adapter: GRAuthAdapter = {\n async authenticate(email, password) {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.signInWithPassword({\n email,\n password,\n });\n\n if (error) return { success: false, error: error.message };\n if (!data.user) return { success: false, error: \"User not found.\" };\n\n return {\n success: true,\n session: { userId: data.user.id, email: data.user.email },\n };\n },\n\n /**\n * Uses getUser(), which revalidates the JWT against the Supabase auth\n * server. getSession() only decodes the cookie, so it would trust a forged\n * token.\n */\n async getSession(): Promise<GRSession | null> {\n const client = await sessionClient(creds);\n const { data, error } = await client.auth.getUser();\n if (error || !data.user) return null;\n return { userId: data.user.id, email: data.user.email };\n },\n\n async logout() {\n const client = await sessionClient(creds);\n await client.auth.signOut();\n },\n\n /**\n * Whichever of hash/code/tokenHash is present decides the exchange —\n * these correspond to Supabase's three redirect styles (legacy implicit\n * grant, PKCE, and the token_hash style Guardrail's own confirm route\n * already used), so this handles a link regardless of which one a\n * project's email templates happen to produce, including the\n * unconfigured default (implicit grant, hash-based — what an unedited\n * Supabase project actually sends).\n *\n * Goes through sessionClient(), the same cookie-writing client\n * everything else in this adapter uses — setSession/exchangeCodeForSession/\n * verifyOtp all persist the resulting session through its existing\n * getAll/setAll bridge, so this needs no browser-side Supabase client of\n * its own.\n */\n async completeAuthRedirect({ hash, code, tokenHash, type }: AuthRedirectParams) {\n const client = await sessionClient(creds);\n\n if (hash) {\n const hashParams = new URLSearchParams(hash);\n const access_token = hashParams.get(\"access_token\");\n const refresh_token = hashParams.get(\"refresh_token\");\n const hashType = hashParams.get(\"type\") ?? type;\n if (!access_token || !refresh_token) {\n return { success: false, error: \"Redirect link is missing its tokens.\" };\n }\n const { error } = await client.auth.setSession({ access_token, refresh_token });\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: needsPasswordFor(hashType) };\n }\n\n if (code) {\n const { error } = await client.auth.exchangeCodeForSession(code);\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: needsPasswordFor(type) };\n }\n\n if (tokenHash && type) {\n const { error } = await client.auth.verifyOtp({\n type: type as EmailOtpType,\n token_hash: tokenHash,\n });\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: needsPasswordFor(type) };\n }\n\n return { success: false, error: \"No redirect data to complete.\" };\n },\n\n async setPassword(password: string) {\n const client = await sessionClient(creds);\n const { error } = await client.auth.updateUser({ password });\n return error ? { success: false, error: error.message } : { success: true };\n },\n };\n\n // Only offered when a service-role key was configured: minting a sign-in link\n // server-side is an admin operation, and an adapter without the key simply\n // can't do it, and the admin is told what to add.\n if (creds.serviceRoleKey) {\n /**\n * Creates the account and returns a one-time link, sending nothing — the\n * site's own onInvite handler delivers it (see resendInvite).\n *\n * The link points at the site's own root with the token in the URL\n * fragment, where the always-mounted AuthRedirectSync completes it. That\n * needs no route and no Supabase Site URL / redirect allowlist entry, and —\n * unlike a GET handler that verifies the token — survives an email scanner\n * that pre-fetches links, since nothing is verified until the page's\n * JavaScript runs.\n */\n adapter.createInviteLink = async (email, { origin }) => {\n const admin = adminClient(creds);\n\n const res = await admin.auth.admin.generateLink({ type: \"invite\", email });\n if (res.error) {\n // Already has an account. They have a password, so there's nothing to\n // hand them but the site's address: a token here would be a way to sign\n // in without it, and a set-password prompt would have them overwrite the\n // password they use. The address opens the sign-in form (?edit=true).\n if (isExistingAccountError(res.error)) {\n return { success: true, link: new URL(\"/?edit=true\", origin).toString(), isNewUser: false };\n }\n return { success: false, error: res.error.message };\n }\n const hashed = res.data?.properties?.hashed_token;\n if (!hashed) return { success: false, error: \"No invite link was returned.\" };\n\n return { success: true, link: tokenLink(origin, hashed, \"invite\"), isNewUser: true };\n };\n\n adapter.createPasswordResetLink = async (email, { origin }) => {\n const res = await adminClient(creds).auth.admin.generateLink({ type: \"recovery\", email });\n const hashed = res.data?.properties?.hashed_token;\n if (res.error || !hashed) {\n return { success: false, error: res.error?.message ?? \"No reset link was returned.\" };\n }\n return { success: true, link: tokenLink(origin, hashed, \"recovery\") };\n };\n }\n\n return adapter;\n}\n"],"mappings":"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","names":[]}
@@ -28,7 +28,12 @@ async function sessionClient(creds) {
28
28
  setAll: (cookiesToSet) => {
29
29
  try {
30
30
  cookiesToSet.forEach(
31
- ({ name, value, options }) => cookieStore.set(name, value, options)
31
+ ({ name, value, options }) => cookieStore.set(name, value, {
32
+ ...options,
33
+ httpOnly: true,
34
+ sameSite: "lax",
35
+ secure: process.env.NODE_ENV === "production"
36
+ })
32
37
  );
33
38
  } catch {
34
39
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/supabase/client.ts"],"sourcesContent":["import { createServerClient } from \"@supabase/ssr\";\nimport { createClient, type SupabaseClient } from \"@supabase/supabase-js\";\nimport { cookies } from \"next/headers\";\n\n/**\n * Credentials are passed in, never read from process.env here.\n *\n * An adapter that reaches into the environment hides its own requirements —\n * nothing in the type signature says which vars must exist, so a missing or\n * stale one surfaces as a runtime fetch failure. It also makes two sites on\n * two different projects impossible, since the credentials become global.\n */\nexport interface SupabaseCredentials {\n url: string;\n anonKey: string;\n /**\n * Optional, and only for branded invite emails (see createInviteLink): it\n * lets Guardrail create an account and mint a sign-in link server-side, so\n * the site can send the email itself. Project-wide by Supabase's design —\n * it bypasses RLS and reaches every schema — so read it from a server-only\n * environment variable (never NEXT_PUBLIC_), and it is used for nothing else.\n */\n serviceRoleKey?: string;\n}\n\nexport function assertCredentials(creds: SupabaseCredentials): void {\n if (!creds?.url || !creds?.anonKey) {\n throw new Error(\n \"Guardrail (Supabase): both `url` and `anonKey` are required.\",\n );\n }\n}\n\nconst anonClients = new Map<string, SupabaseClient>();\n\n/**\n * Cookie-free client for reads. A single cookies() call in the render path\n * opts the whole route out of static generation, so public content reads must\n * never touch them.\n */\nexport function anonClient(creds: SupabaseCredentials): SupabaseClient {\n const key = `${creds.url}::${creds.anonKey}`;\n let client = anonClients.get(key);\n if (!client) {\n client = createClient(creds.url, creds.anonKey, {\n auth: { persistSession: false, autoRefreshToken: false },\n });\n anonClients.set(key, client);\n }\n return client;\n}\n\n/**\n * Request-scoped client carrying the editor's auth cookies, so writes run as\n * the signed-in user and RLS can enforce authorization independently.\n */\nexport async function sessionClient(\n creds: SupabaseCredentials,\n): Promise<SupabaseClient> {\n const cookieStore = await cookies();\n\n return createServerClient(creds.url, creds.anonKey, {\n cookies: {\n getAll: () => cookieStore.getAll(),\n setAll: (cookiesToSet) => {\n try {\n cookiesToSet.forEach(({ name, value, options }) =>\n cookieStore.set(name, value, options),\n );\n } catch {\n // Read-only cookies in a Server Component; safe to ignore when a\n // Server Action or middleware refreshes the session.\n }\n },\n },\n });\n}\n\n/**\n * Service-role client, for the one admin call Guardrail makes with it\n * (generating an invite link). Throws if no key was configured — callers are\n * expected to have checked, since the adapter only exposes that capability when\n * one was.\n */\nexport function adminClient(creds: SupabaseCredentials): SupabaseClient {\n if (!creds.serviceRoleKey) {\n throw new Error(\"Guardrail (Supabase): serviceRoleKey is not configured.\");\n }\n return createClient(creds.url, creds.serviceRoleKey, {\n auth: { persistSession: false, autoRefreshToken: false },\n });\n}\n"],"mappings":"AAAA,SAAS,0BAA0B;AACnC,SAAS,oBAAyC;AAClD,SAAS,eAAe;AAuBjB,SAAS,kBAAkB,OAAkC;AAClE,MAAI,CAAC,OAAO,OAAO,CAAC,OAAO,SAAS;AAClC,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;AAEA,MAAM,cAAc,oBAAI,IAA4B;AAO7C,SAAS,WAAW,OAA4C;AACrE,QAAM,MAAM,GAAG,MAAM,GAAG,KAAK,MAAM,OAAO;AAC1C,MAAI,SAAS,YAAY,IAAI,GAAG;AAChC,MAAI,CAAC,QAAQ;AACX,aAAS,aAAa,MAAM,KAAK,MAAM,SAAS;AAAA,MAC9C,MAAM,EAAE,gBAAgB,OAAO,kBAAkB,MAAM;AAAA,IACzD,CAAC;AACD,gBAAY,IAAI,KAAK,MAAM;AAAA,EAC7B;AACA,SAAO;AACT;AAMA,eAAsB,cACpB,OACyB;AACzB,QAAM,cAAc,MAAM,QAAQ;AAElC,SAAO,mBAAmB,MAAM,KAAK,MAAM,SAAS;AAAA,IAClD,SAAS;AAAA,MACP,QAAQ,MAAM,YAAY,OAAO;AAAA,MACjC,QAAQ,CAAC,iBAAiB;AACxB,YAAI;AACF,uBAAa;AAAA,YAAQ,CAAC,EAAE,MAAM,OAAO,QAAQ,MAC3C,YAAY,IAAI,MAAM,OAAO,OAAO;AAAA,UACtC;AAAA,QACF,QAAQ;AAAA,QAGR;AAAA,MACF;AAAA,IACF;AAAA,EACF,CAAC;AACH;AAQO,SAAS,YAAY,OAA4C;AACtE,MAAI,CAAC,MAAM,gBAAgB;AACzB,UAAM,IAAI,MAAM,yDAAyD;AAAA,EAC3E;AACA,SAAO,aAAa,MAAM,KAAK,MAAM,gBAAgB;AAAA,IACnD,MAAM,EAAE,gBAAgB,OAAO,kBAAkB,MAAM;AAAA,EACzD,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../../../src/adapters/supabase/client.ts"],"sourcesContent":["import { createServerClient } from \"@supabase/ssr\";\nimport { createClient, type SupabaseClient } from \"@supabase/supabase-js\";\nimport { cookies } from \"next/headers\";\n\n/**\n * Credentials are passed in, never read from process.env here.\n *\n * An adapter that reaches into the environment hides its own requirements —\n * nothing in the type signature says which vars must exist, so a missing or\n * stale one surfaces as a runtime fetch failure. It also makes two sites on\n * two different projects impossible, since the credentials become global.\n */\nexport interface SupabaseCredentials {\n url: string;\n anonKey: string;\n /**\n * Optional, and only for branded invite emails (see createInviteLink): it\n * lets Guardrail create an account and mint a sign-in link server-side, so\n * the site can send the email itself. Project-wide by Supabase's design —\n * it bypasses RLS and reaches every schema — so read it from a server-only\n * environment variable (never NEXT_PUBLIC_), and it is used for nothing else.\n */\n serviceRoleKey?: string;\n}\n\nexport function assertCredentials(creds: SupabaseCredentials): void {\n if (!creds?.url || !creds?.anonKey) {\n throw new Error(\n \"Guardrail (Supabase): both `url` and `anonKey` are required.\",\n );\n }\n}\n\nconst anonClients = new Map<string, SupabaseClient>();\n\n/**\n * Cookie-free client for reads. A single cookies() call in the render path\n * opts the whole route out of static generation, so public content reads must\n * never touch them.\n */\nexport function anonClient(creds: SupabaseCredentials): SupabaseClient {\n const key = `${creds.url}::${creds.anonKey}`;\n let client = anonClients.get(key);\n if (!client) {\n client = createClient(creds.url, creds.anonKey, {\n auth: { persistSession: false, autoRefreshToken: false },\n });\n anonClients.set(key, client);\n }\n return client;\n}\n\n/**\n * Request-scoped client carrying the editor's auth cookies, so writes run as\n * the signed-in user and RLS can enforce authorization independently.\n */\nexport async function sessionClient(\n creds: SupabaseCredentials,\n): Promise<SupabaseClient> {\n const cookieStore = await cookies();\n\n return createServerClient(creds.url, creds.anonKey, {\n cookies: {\n getAll: () => cookieStore.getAll(),\n setAll: (cookiesToSet) => {\n try {\n // The session is only ever read on the server (there's no browser\n // Supabase client), so nothing in the page needs to see it: HttpOnly\n // keeps an injected script from lifting the tokens, and Secure keeps\n // them off plain-HTTP requests. @supabase/ssr's defaults are neither.\n cookiesToSet.forEach(({ name, value, options }) =>\n cookieStore.set(name, value, {\n ...options,\n httpOnly: true,\n sameSite: \"lax\",\n secure: process.env.NODE_ENV === \"production\",\n }),\n );\n } catch {\n // Read-only cookies in a Server Component; safe to ignore when a\n // Server Action or middleware refreshes the session.\n }\n },\n },\n });\n}\n\n/**\n * Service-role client, for the one admin call Guardrail makes with it\n * (generating an invite link). Throws if no key was configured — callers are\n * expected to have checked, since the adapter only exposes that capability when\n * one was.\n */\nexport function adminClient(creds: SupabaseCredentials): SupabaseClient {\n if (!creds.serviceRoleKey) {\n throw new Error(\"Guardrail (Supabase): serviceRoleKey is not configured.\");\n }\n return createClient(creds.url, creds.serviceRoleKey, {\n auth: { persistSession: false, autoRefreshToken: false },\n });\n}\n"],"mappings":"AAAA,SAAS,0BAA0B;AACnC,SAAS,oBAAyC;AAClD,SAAS,eAAe;AAuBjB,SAAS,kBAAkB,OAAkC;AAClE,MAAI,CAAC,OAAO,OAAO,CAAC,OAAO,SAAS;AAClC,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;AAEA,MAAM,cAAc,oBAAI,IAA4B;AAO7C,SAAS,WAAW,OAA4C;AACrE,QAAM,MAAM,GAAG,MAAM,GAAG,KAAK,MAAM,OAAO;AAC1C,MAAI,SAAS,YAAY,IAAI,GAAG;AAChC,MAAI,CAAC,QAAQ;AACX,aAAS,aAAa,MAAM,KAAK,MAAM,SAAS;AAAA,MAC9C,MAAM,EAAE,gBAAgB,OAAO,kBAAkB,MAAM;AAAA,IACzD,CAAC;AACD,gBAAY,IAAI,KAAK,MAAM;AAAA,EAC7B;AACA,SAAO;AACT;AAMA,eAAsB,cACpB,OACyB;AACzB,QAAM,cAAc,MAAM,QAAQ;AAElC,SAAO,mBAAmB,MAAM,KAAK,MAAM,SAAS;AAAA,IAClD,SAAS;AAAA,MACP,QAAQ,MAAM,YAAY,OAAO;AAAA,MACjC,QAAQ,CAAC,iBAAiB;AACxB,YAAI;AAKF,uBAAa;AAAA,YAAQ,CAAC,EAAE,MAAM,OAAO,QAAQ,MAC3C,YAAY,IAAI,MAAM,OAAO;AAAA,cAC3B,GAAG;AAAA,cACH,UAAU;AAAA,cACV,UAAU;AAAA,cACV,QAAQ,QAAQ,IAAI,aAAa;AAAA,YACnC,CAAC;AAAA,UACH;AAAA,QACF,QAAQ;AAAA,QAGR;AAAA,MACF;AAAA,IACF;AAAA,EACF,CAAC;AACH;AAQO,SAAS,YAAY,OAA4C;AACtE,MAAI,CAAC,MAAM,gBAAgB;AACzB,UAAM,IAAI,MAAM,yDAAyD;AAAA,EAC3E;AACA,SAAO,aAAa,MAAM,KAAK,MAAM,gBAAgB;AAAA,IACnD,MAAM,EAAE,gBAAgB,OAAO,kBAAkB,MAAM;AAAA,EACzD,CAAC;AACH;","names":[]}
@@ -2,20 +2,20 @@ import { SupabaseCredentials } from './client.js';
2
2
  import '@supabase/supabase-js';
3
3
 
4
4
  /**
5
- * Route handler that turns an emailed link into a session.
5
+ * Route handler for emailed links of the older `/guardrail/confirm?token_hash=…`
6
+ * shape. Guardrail's own links no longer point here — they carry the token in
7
+ * the URL fragment of the site root, and need no route at all — but links
8
+ * already sitting in inboxes still do, so this keeps them working.
6
9
  *
7
- * Mount it in the app:
10
+ * It deliberately does NOT verify the token. A GET that spends a one-time token
11
+ * is burned by anything that prefetches links (email scanners, link previews)
12
+ * and can be used to sign a victim into someone else's session. Instead it
13
+ * hands the token to the browser in the URL fragment, where the always-mounted
14
+ * AuthRedirectSync completes it — the same path a fresh link takes.
15
+ *
16
+ * Mount it in the app only if you have links in the wild:
8
17
  * // app/guardrail/confirm/route.ts
9
18
  * export const GET = supabaseConfirmRoute({ url, anonKey });
10
- *
11
- * verifyOtp with a token_hash works in whichever browser opens the link, unlike
12
- * a PKCE code exchange. Once the session cookie is set, the next render claims
13
- * any pending invitation addressed to that email.
14
- *
15
- * An invite, magic link, or recovery link also sets the same "needs a
16
- * password" flag the in-page redirect handler does, so the person lands on the
17
- * Set Password prompt rather than silently signed in with no way to log in
18
- * next time — both ways of completing a link behave the same.
19
19
  */
20
20
  declare function supabaseConfirmRoute(creds: SupabaseCredentials, options?: {
21
21
  next?: string;
@@ -1,10 +1,13 @@
1
- import { cookies } from "next/headers";
2
- import { redirect } from "next/navigation";
3
- import { assertCredentials, sessionClient } from "./client";
4
- import { needsPasswordFor } from "./auth";
5
- import { GR_COOKIES, GR_NEEDS_PASSWORD_TTL_SECONDS } from "../../core/defs";
1
+ import { assertCredentials } from "./client";
6
2
  function safePath(value, fallback) {
7
3
  if (!value || !value.startsWith("/") || value.startsWith("//")) return fallback;
4
+ if (/[\\\u0000-\u001f\u007f]/.test(value)) return fallback;
5
+ try {
6
+ const base = "http://localhost";
7
+ if (new URL(value, base).origin !== base) return fallback;
8
+ } catch {
9
+ return fallback;
10
+ }
8
11
  return value;
9
12
  }
10
13
  function supabaseConfirmRoute(creds, options = {}) {
@@ -16,20 +19,13 @@ function supabaseConfirmRoute(creds, options = {}) {
16
19
  const type = url.searchParams.get("type");
17
20
  const next = safePath(url.searchParams.get("next"), fallback);
18
21
  if (tokenHash && type) {
19
- const client = await sessionClient(creds);
20
- const { error } = await client.auth.verifyOtp({ type, token_hash: tokenHash });
21
- if (!error) {
22
- if (needsPasswordFor(type)) {
23
- (await cookies()).set(GR_COOKIES.NEEDS_PASSWORD, "true", {
24
- path: "/",
25
- maxAge: GR_NEEDS_PASSWORD_TTL_SECONDS
26
- });
27
- }
28
- redirect(next);
29
- }
30
- console.error("Guardrail (Supabase): link verification failed", error.message);
22
+ const fragment = new URLSearchParams({ token_hash: tokenHash, type }).toString();
23
+ return Response.redirect(new URL(`${next.split("#")[0]}#${fragment}`, url), 303);
31
24
  }
32
- redirect(`${fallback}${fallback.includes("?") ? "&" : "?"}gr_auth_error=link`);
25
+ return Response.redirect(
26
+ new URL(`${fallback}${fallback.includes("?") ? "&" : "?"}gr_auth_error=link`, url),
27
+ 303
28
+ );
33
29
  };
34
30
  }
35
31
  export {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/supabase/confirm.ts"],"sourcesContent":["import type { EmailOtpType } from \"@supabase/supabase-js\";\nimport { cookies } from \"next/headers\";\nimport { redirect } from \"next/navigation\";\nimport { assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport { needsPasswordFor } from \"./auth\";\nimport { GR_COOKIES, GR_NEEDS_PASSWORD_TTL_SECONDS } from \"../../core/defs\";\n\n/**\n * Only same-origin paths. Accepting an arbitrary `next` would make this route\n * an open redirect — a signed-in link on your domain that forwards anywhere.\n */\nfunction safePath(value: string | null, fallback: string): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\")) return fallback;\n return value;\n}\n\n/**\n * Route handler that turns an emailed link into a session.\n *\n * Mount it in the app:\n * // app/guardrail/confirm/route.ts\n * export const GET = supabaseConfirmRoute({ url, anonKey });\n *\n * verifyOtp with a token_hash works in whichever browser opens the link, unlike\n * a PKCE code exchange. Once the session cookie is set, the next render claims\n * any pending invitation addressed to that email.\n *\n * An invite, magic link, or recovery link also sets the same \"needs a\n * password\" flag the in-page redirect handler does, so the person lands on the\n * Set Password prompt rather than silently signed in with no way to log in\n * next time — both ways of completing a link behave the same.\n */\nexport function supabaseConfirmRoute(\n creds: SupabaseCredentials,\n options: { next?: string } = {},\n) {\n assertCredentials(creds);\n const fallback = options.next ?? \"/?edit=true\";\n\n return async function GET(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const tokenHash = url.searchParams.get(\"token_hash\");\n const type = url.searchParams.get(\"type\") as EmailOtpType | null;\n const next = safePath(url.searchParams.get(\"next\"), fallback);\n\n if (tokenHash && type) {\n const client = await sessionClient(creds);\n const { error } = await client.auth.verifyOtp({ type, token_hash: tokenHash });\n if (!error) {\n if (needsPasswordFor(type)) {\n (await cookies()).set(GR_COOKIES.NEEDS_PASSWORD, \"true\", {\n path: \"/\",\n maxAge: GR_NEEDS_PASSWORD_TTL_SECONDS,\n });\n }\n redirect(next);\n }\n console.error(\"Guardrail (Supabase): link verification failed\", error.message);\n }\n\n redirect(`${fallback}${fallback.includes(\"?\") ? \"&\" : \"?\"}gr_auth_error=link`);\n };\n}\n"],"mappings":"AACA,SAAS,eAAe;AACxB,SAAS,gBAAgB;AACzB,SAAS,mBAAmB,qBAAqB;AAEjD,SAAS,wBAAwB;AACjC,SAAS,YAAY,qCAAqC;AAM1D,SAAS,SAAS,OAAsB,UAA0B;AAChE,MAAI,CAAC,SAAS,CAAC,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,EAAG,QAAO;AACvE,SAAO;AACT;AAkBO,SAAS,qBACd,OACA,UAA6B,CAAC,GAC9B;AACA,oBAAkB,KAAK;AACvB,QAAM,WAAW,QAAQ,QAAQ;AAEjC,SAAO,eAAe,IAAI,SAAqC;AAC7D,UAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,UAAM,YAAY,IAAI,aAAa,IAAI,YAAY;AACnD,UAAM,OAAO,IAAI,aAAa,IAAI,MAAM;AACxC,UAAM,OAAO,SAAS,IAAI,aAAa,IAAI,MAAM,GAAG,QAAQ;AAE5D,QAAI,aAAa,MAAM;AACrB,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,IAAI,MAAM,OAAO,KAAK,UAAU,EAAE,MAAM,YAAY,UAAU,CAAC;AAC7E,UAAI,CAAC,OAAO;AACV,YAAI,iBAAiB,IAAI,GAAG;AAC1B,WAAC,MAAM,QAAQ,GAAG,IAAI,WAAW,gBAAgB,QAAQ;AAAA,YACvD,MAAM;AAAA,YACN,QAAQ;AAAA,UACV,CAAC;AAAA,QACH;AACA,iBAAS,IAAI;AAAA,MACf;AACA,cAAQ,MAAM,kDAAkD,MAAM,OAAO;AAAA,IAC/E;AAEA,aAAS,GAAG,QAAQ,GAAG,SAAS,SAAS,GAAG,IAAI,MAAM,GAAG,oBAAoB;AAAA,EAC/E;AACF;","names":[]}
1
+ {"version":3,"sources":["../../../src/adapters/supabase/confirm.ts"],"sourcesContent":["import { assertCredentials } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\n\n/**\n * Only same-origin paths. Accepting an arbitrary `next` would make this route\n * an open redirect — a signed-in link on your domain that forwards anywhere.\n */\nfunction safePath(value: string | null, fallback: string): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\")) return fallback;\n // Browsers treat \"\\\" as \"/\" and drop tab/CR/LF inside a URL, so \"/\\evil.com\"\n // and \"/\\t/evil.com\" both end up as \"//evil.com\". Refuse them outright, then\n // confirm by parsing that the result still lands on this origin.\n if (/[\\\\\\u0000-\\u001f\\u007f]/.test(value)) return fallback;\n try {\n const base = \"http://localhost\";\n if (new URL(value, base).origin !== base) return fallback;\n } catch {\n return fallback;\n }\n return value;\n}\n\n/**\n * Route handler for emailed links of the older `/guardrail/confirm?token_hash=…`\n * shape. Guardrail's own links no longer point here — they carry the token in\n * the URL fragment of the site root, and need no route at all — but links\n * already sitting in inboxes still do, so this keeps them working.\n *\n * It deliberately does NOT verify the token. A GET that spends a one-time token\n * is burned by anything that prefetches links (email scanners, link previews)\n * and can be used to sign a victim into someone else's session. Instead it\n * hands the token to the browser in the URL fragment, where the always-mounted\n * AuthRedirectSync completes it — the same path a fresh link takes.\n *\n * Mount it in the app only if you have links in the wild:\n * // app/guardrail/confirm/route.ts\n * export const GET = supabaseConfirmRoute({ url, anonKey });\n */\nexport function supabaseConfirmRoute(\n creds: SupabaseCredentials,\n options: { next?: string } = {},\n) {\n assertCredentials(creds);\n const fallback = options.next ?? \"/?edit=true\";\n\n return async function GET(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const tokenHash = url.searchParams.get(\"token_hash\");\n const type = url.searchParams.get(\"type\");\n const next = safePath(url.searchParams.get(\"next\"), fallback);\n\n if (tokenHash && type) {\n const fragment = new URLSearchParams({ token_hash: tokenHash, type }).toString();\n return Response.redirect(new URL(`${next.split(\"#\")[0]}#${fragment}`, url), 303);\n }\n\n return Response.redirect(\n new URL(`${fallback}${fallback.includes(\"?\") ? \"&\" : \"?\"}gr_auth_error=link`, url),\n 303,\n );\n };\n}\n"],"mappings":"AAAA,SAAS,yBAAyB;AAOlC,SAAS,SAAS,OAAsB,UAA0B;AAChE,MAAI,CAAC,SAAS,CAAC,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,EAAG,QAAO;AAIvE,MAAI,0BAA0B,KAAK,KAAK,EAAG,QAAO;AAClD,MAAI;AACF,UAAM,OAAO;AACb,QAAI,IAAI,IAAI,OAAO,IAAI,EAAE,WAAW,KAAM,QAAO;AAAA,EACnD,QAAQ;AACN,WAAO;AAAA,EACT;AACA,SAAO;AACT;AAkBO,SAAS,qBACd,OACA,UAA6B,CAAC,GAC9B;AACA,oBAAkB,KAAK;AACvB,QAAM,WAAW,QAAQ,QAAQ;AAEjC,SAAO,eAAe,IAAI,SAAqC;AAC7D,UAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,UAAM,YAAY,IAAI,aAAa,IAAI,YAAY;AACnD,UAAM,OAAO,IAAI,aAAa,IAAI,MAAM;AACxC,UAAM,OAAO,SAAS,IAAI,aAAa,IAAI,MAAM,GAAG,QAAQ;AAE5D,QAAI,aAAa,MAAM;AACrB,YAAM,WAAW,IAAI,gBAAgB,EAAE,YAAY,WAAW,KAAK,CAAC,EAAE,SAAS;AAC/E,aAAO,SAAS,SAAS,IAAI,IAAI,GAAG,KAAK,MAAM,GAAG,EAAE,CAAC,CAAC,IAAI,QAAQ,IAAI,GAAG,GAAG,GAAG;AAAA,IACjF;AAEA,WAAO,SAAS;AAAA,MACd,IAAI,IAAI,GAAG,QAAQ,GAAG,SAAS,SAAS,GAAG,IAAI,MAAM,GAAG,sBAAsB,GAAG;AAAA,MACjF;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
@@ -1,10 +1,11 @@
1
- import { GRDBAdapter, GRAuthAdapter, GRMembershipAdapter, GRRepeaterAdapter } from '../../core/adapter-types.js';
1
+ import { GRDBAdapter, GRAuthAdapter, GRMembershipAdapter, GRRepeaterAdapter, GRRateLimiter } from '../../core/adapter-types.js';
2
2
  export { supabaseDB } from './db.js';
3
3
  export { supabaseAuth } from './auth.js';
4
4
  export { supabaseMembers } from './members.js';
5
5
  export { supabaseConfirmRoute } from './confirm.js';
6
6
  export { DEFAULT_BUCKET, SupabaseStorageOptions, supabaseStorage } from './storage.js';
7
7
  export { supabaseRepeaters } from './repeaters.js';
8
+ export { supabaseRateLimit } from './rateLimit.js';
8
9
  import { SupabaseCredentials } from './client.js';
9
10
  import 'react';
10
11
  import '../../core/types.js';
@@ -20,6 +21,7 @@ declare function supabaseAdapters(creds: SupabaseCredentials): {
20
21
  auth: GRAuthAdapter;
21
22
  members: GRMembershipAdapter;
22
23
  repeaters: GRRepeaterAdapter;
24
+ rateLimit?: GRRateLimiter | undefined;
23
25
  };
24
26
 
25
27
  export { SupabaseCredentials, supabaseAdapters };
@@ -4,12 +4,16 @@ import { supabaseMembers } from "./members";
4
4
  import { supabaseConfirmRoute } from "./confirm";
5
5
  import { supabaseStorage, DEFAULT_BUCKET } from "./storage";
6
6
  import { supabaseRepeaters } from "./repeaters";
7
+ import { supabaseRateLimit } from "./rateLimit";
7
8
  import { supabaseDB as supabaseDB2 } from "./db";
8
9
  import { supabaseAuth as supabaseAuth2 } from "./auth";
9
10
  import { supabaseMembers as supabaseMembers2 } from "./members";
10
11
  import { supabaseRepeaters as supabaseRepeaters2 } from "./repeaters";
12
+ import { supabaseRateLimit as supabaseRateLimit2 } from "./rateLimit";
11
13
  function supabaseAdapters(creds) {
14
+ const rateLimit = supabaseRateLimit2(creds);
12
15
  return {
16
+ ...rateLimit ? { rateLimit } : {},
13
17
  db: supabaseDB2(creds),
14
18
  auth: supabaseAuth2(creds),
15
19
  members: supabaseMembers2(creds),
@@ -23,6 +27,7 @@ export {
23
27
  supabaseConfirmRoute,
24
28
  supabaseDB,
25
29
  supabaseMembers,
30
+ supabaseRateLimit,
26
31
  supabaseRepeaters,
27
32
  supabaseStorage
28
33
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/supabase/index.ts"],"sourcesContent":["export { supabaseDB } from \"./db\";\nexport { supabaseAuth } from \"./auth\";\nexport { supabaseMembers } from \"./members\";\nexport { supabaseConfirmRoute } from \"./confirm\";\nexport { supabaseStorage, DEFAULT_BUCKET } from \"./storage\";\nexport { supabaseRepeaters } from \"./repeaters\";\nexport type { SupabaseStorageOptions } from \"./storage\";\nexport type { SupabaseCredentials } from \"./client\";\n\nimport { supabaseDB } from \"./db\";\nimport { supabaseAuth } from \"./auth\";\nimport { supabaseMembers } from \"./members\";\nimport { supabaseRepeaters } from \"./repeaters\";\nimport type { SupabaseCredentials } from \"./client\";\n\n/**\n * All three slots backed by one Supabase project. Use the individual factories\n * instead when mixing backends — Supabase for content, another provider for\n * identity, say.\n */\nexport function supabaseAdapters(creds: SupabaseCredentials) {\n return {\n db: supabaseDB(creds),\n auth: supabaseAuth(creds),\n members: supabaseMembers(creds),\n repeaters: supabaseRepeaters(creds),\n };\n}\n"],"mappings":"AAAA,SAAS,kBAAkB;AAC3B,SAAS,oBAAoB;AAC7B,SAAS,uBAAuB;AAChC,SAAS,4BAA4B;AACrC,SAAS,iBAAiB,sBAAsB;AAChD,SAAS,yBAAyB;AAIlC,SAAS,cAAAA,mBAAkB;AAC3B,SAAS,gBAAAC,qBAAoB;AAC7B,SAAS,mBAAAC,wBAAuB;AAChC,SAAS,qBAAAC,0BAAyB;AAQ3B,SAAS,iBAAiB,OAA4B;AAC3D,SAAO;AAAA,IACL,IAAIH,YAAW,KAAK;AAAA,IACpB,MAAMC,cAAa,KAAK;AAAA,IACxB,SAASC,iBAAgB,KAAK;AAAA,IAC9B,WAAWC,mBAAkB,KAAK;AAAA,EACpC;AACF;","names":["supabaseDB","supabaseAuth","supabaseMembers","supabaseRepeaters"]}
1
+ {"version":3,"sources":["../../../src/adapters/supabase/index.ts"],"sourcesContent":["export { supabaseDB } from \"./db\";\nexport { supabaseAuth } from \"./auth\";\nexport { supabaseMembers } from \"./members\";\nexport { supabaseConfirmRoute } from \"./confirm\";\nexport { supabaseStorage, DEFAULT_BUCKET } from \"./storage\";\nexport { supabaseRepeaters } from \"./repeaters\";\nexport { supabaseRateLimit } from \"./rateLimit\";\nexport type { SupabaseStorageOptions } from \"./storage\";\nexport type { SupabaseCredentials } from \"./client\";\n\nimport { supabaseDB } from \"./db\";\nimport { supabaseAuth } from \"./auth\";\nimport { supabaseMembers } from \"./members\";\nimport { supabaseRepeaters } from \"./repeaters\";\nimport { supabaseRateLimit } from \"./rateLimit\";\nimport type { SupabaseCredentials } from \"./client\";\n\n/**\n * All three slots backed by one Supabase project. Use the individual factories\n * instead when mixing backends — Supabase for content, another provider for\n * identity, say.\n */\nexport function supabaseAdapters(creds: SupabaseCredentials) {\n // Database-backed limits for sign-in, resets and the like, whenever there's a\n // service-role key to make the call with. Without one the built-in in-memory\n // limiter applies.\n const rateLimit = supabaseRateLimit(creds);\n return {\n ...(rateLimit ? { rateLimit } : {}),\n db: supabaseDB(creds),\n auth: supabaseAuth(creds),\n members: supabaseMembers(creds),\n repeaters: supabaseRepeaters(creds),\n };\n}\n"],"mappings":"AAAA,SAAS,kBAAkB;AAC3B,SAAS,oBAAoB;AAC7B,SAAS,uBAAuB;AAChC,SAAS,4BAA4B;AACrC,SAAS,iBAAiB,sBAAsB;AAChD,SAAS,yBAAyB;AAClC,SAAS,yBAAyB;AAIlC,SAAS,cAAAA,mBAAkB;AAC3B,SAAS,gBAAAC,qBAAoB;AAC7B,SAAS,mBAAAC,wBAAuB;AAChC,SAAS,qBAAAC,0BAAyB;AAClC,SAAS,qBAAAC,0BAAyB;AAQ3B,SAAS,iBAAiB,OAA4B;AAI3D,QAAM,YAAYA,mBAAkB,KAAK;AACzC,SAAO;AAAA,IACL,GAAI,YAAY,EAAE,UAAU,IAAI,CAAC;AAAA,IACjC,IAAIJ,YAAW,KAAK;AAAA,IACpB,MAAMC,cAAa,KAAK;AAAA,IACxB,SAASC,iBAAgB,KAAK;AAAA,IAC9B,WAAWC,mBAAkB,KAAK;AAAA,EACpC;AACF;","names":["supabaseDB","supabaseAuth","supabaseMembers","supabaseRepeaters","supabaseRateLimit"]}
@@ -0,0 +1,24 @@
1
+ import { SupabaseCredentials } from './client.js';
2
+ import { GRRateLimiter } from '../../core/adapter-types.js';
3
+ import '@supabase/supabase-js';
4
+ import 'react';
5
+ import '../../core/types.js';
6
+
7
+ /**
8
+ * Rate-limit counters in the project's own `guardrail` schema (see the
9
+ * rate_limits migration), so a site gets limits that hold across serverless
10
+ * instances with nothing extra to run. One database round trip per counted
11
+ * attempt — slower than Redis, and fine at the rate people sign in.
12
+ *
13
+ * Needs the service-role key, because sign-in and "Forgot password?" have no
14
+ * session to carry the call. Used automatically by supabaseAdapters() when
15
+ * that key is present.
16
+ *
17
+ * Failure handling: if the migration hasn't been applied yet (the function
18
+ * doesn't exist), this warns once and counts in memory instead — an upgrade
19
+ * that forgot the migration shouldn't lock everyone out of signing in. Any
20
+ * other database error throws, which blocks the request (fail closed).
21
+ */
22
+ declare function supabaseRateLimit(creds: SupabaseCredentials): GRRateLimiter | undefined;
23
+
24
+ export { supabaseRateLimit };