@guard-rail/core 0.9.0 → 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 +182 -38
- 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/email/console.d.ts +24 -0
- package/dist/email/console.js +30 -0
- package/dist/email/console.js.map +1 -0
- package/dist/index.client.d.ts +3 -2
- package/dist/index.client.js +5 -0
- package/dist/index.client.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -0
- 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 +15 -14
- 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
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ pnpm add @guard-rail/core @supabase/ssr @supabase/supabase-js
|
|
|
25
25
|
|
|
26
26
|
Set up the database:
|
|
27
27
|
|
|
28
|
-
1. Run `
|
|
28
|
+
1. Run `pnpm exec guardrail-init-db --push`. It copies the package's migration(s)
|
|
29
29
|
into your own project's `supabase/migrations/` (skipping any already
|
|
30
30
|
there — safe to re-run after upgrading), runs `supabase init` if needed,
|
|
31
31
|
adds `guardrail` to `config.toml`'s exposed schemas for local dev, and
|
|
@@ -50,36 +50,8 @@ SUPABASE_ANON_KEY=...
|
|
|
50
50
|
GUARDRAIL_SITE_ID=my-site # optional; defaults to "default"
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
-
|
|
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.**
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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.
|
|
@@ -273,6 +251,18 @@ accounts and links, and to check membership for "Forgot password?". The Resend
|
|
|
273
251
|
key belongs in the site's server environment too; a *sending-only* key limited
|
|
274
252
|
to your domain is plenty.
|
|
275
253
|
|
|
254
|
+
**While developing,** you don't need a mail provider: `consoleEmails()` prints
|
|
255
|
+
each invite and reset link to your terminal instead of sending it, so the whole
|
|
256
|
+
flow can be tried locally. It refuses in production — a link is as good as a
|
|
257
|
+
password for choosing one, so it doesn't belong in a log — and tells the admin
|
|
258
|
+
what to configure instead:
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
...(process.env.GUARDRAIL_EMAIL_FROM
|
|
262
|
+
? resendEmails({ from: process.env.GUARDRAIL_EMAIL_FROM, siteName: "My Site" })
|
|
263
|
+
: consoleEmails()),
|
|
264
|
+
```
|
|
265
|
+
|
|
276
266
|
**Your own wording or provider.** `render` and `renderReset` replace the default
|
|
277
267
|
emails; any other provider just needs an `onInvite` and `onPasswordReset` — each
|
|
278
268
|
receives the same event, including the `link`:
|
|
@@ -302,7 +292,7 @@ as letting anyone make themselves an admin. Seed the first admin with the
|
|
|
302
292
|
bundled command instead of hand-writing SQL:
|
|
303
293
|
|
|
304
294
|
```bash
|
|
305
|
-
SERVICE_KEY=...
|
|
295
|
+
SERVICE_KEY=... pnpm exec guardrail-seed-user --email=you@example.com --password=...
|
|
306
296
|
```
|
|
307
297
|
|
|
308
298
|
`--site-id` defaults to `GUARDRAIL_SITE_ID` from your `.env.local` (and
|
|
@@ -349,7 +339,7 @@ the file after resizing.
|
|
|
349
339
|
1. The init migration creates the default `gr-media` bucket and storage
|
|
350
340
|
policies scoped to the `gr-` prefix. To use a different bucket, just pass
|
|
351
341
|
its name: any `gr-*` bucket is created automatically on the first upload,
|
|
352
|
-
no SQL edits needed.
|
|
342
|
+
no SQL edits needed (up to 50 per project).
|
|
353
343
|
2. Allow the storage host in `next.config.ts`, or `next/image` will throw:
|
|
354
344
|
```ts
|
|
355
345
|
images: {
|
|
@@ -599,8 +589,162 @@ Anything outside the preload (a `prefetch.prefix` scope, or a site past the row
|
|
|
599
589
|
ceiling) falls back to a batched lookup by key. Set `GUARDRAIL_DEBUG=1` to log
|
|
600
590
|
what each query resolved.
|
|
601
591
|
|
|
592
|
+
## Checking your setup: `guardrail-doctor`
|
|
593
|
+
|
|
594
|
+
`guardrail-doctor` ships inside this package, so it always matches the version
|
|
595
|
+
you have installed. Run it after installing, after every upgrade, and before you
|
|
596
|
+
deploy:
|
|
597
|
+
|
|
598
|
+
```bash
|
|
599
|
+
pnpm exec guardrail-doctor
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
By default it only reads. It checks:
|
|
603
|
+
|
|
604
|
+
- **Project:** the installed versions of `next`, `@supabase/supabase-js`,
|
|
605
|
+
`@supabase/ssr` and `@guard-rail/core` against the npm registry's published
|
|
606
|
+
security advisories (always current — nothing to keep up to date), and that
|
|
607
|
+
the Supabase libraries are installed.
|
|
608
|
+
- **Environment:** the Supabase URL and keys (including keys swapped into each
|
|
609
|
+
other's slots, and secrets behind a `NEXT_PUBLIC_` name), `GUARDRAIL_SITE_URL`,
|
|
610
|
+
email settings, and that your env files are gitignored.
|
|
611
|
+
- **Files:** that every migration this version ships is in
|
|
612
|
+
`supabase/migrations/`, that `next.config` sets anti-framing headers, and that
|
|
613
|
+
the root layout uses `withGuardrail()`.
|
|
614
|
+
- **Database:** that the `guardrail` schema is exposed, the tables and functions
|
|
615
|
+
exist, rate limiting works, and what your project's sign-up settings are
|
|
616
|
+
(whether **Confirm email** is on).
|
|
617
|
+
|
|
618
|
+
| Flag | What it adds |
|
|
619
|
+
|---|---|
|
|
620
|
+
| `--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`. |
|
|
621
|
+
| `--app-url=http://localhost:3000` | Also checks the response headers of a running copy of your site. |
|
|
622
|
+
| `--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. |
|
|
623
|
+
| `--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. |
|
|
624
|
+
| `--production` | Treats production-only requirements as failures instead of warnings. |
|
|
625
|
+
| `--strict` / `--json` | Exit non-zero on warnings too; machine-readable output for CI. |
|
|
626
|
+
|
|
627
|
+
It exits non-zero when anything fails, so it works in CI. It reads
|
|
628
|
+
`SUPABASE_URL`, `SUPABASE_ANON_KEY` and `SUPABASE_SERVICE_ROLE_KEY` (or
|
|
629
|
+
`SERVICE_KEY`) from the environment, then `.env.local` and `.env`.
|
|
630
|
+
|
|
631
|
+
### Upgrading
|
|
632
|
+
|
|
633
|
+
After `pnpm up @guard-rail/core`:
|
|
634
|
+
|
|
635
|
+
```bash
|
|
636
|
+
pnpm exec guardrail-doctor --fix --push # copy + apply new migrations, patch local files
|
|
637
|
+
pnpm exec guardrail-doctor # see what's left for you to do
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
What `--fix` can't do for you: set the value of `GUARDRAIL_SITE_URL`, turn on
|
|
641
|
+
**Confirm email** in the Supabase dashboard, and merge headers into a
|
|
642
|
+
`next.config` that already defines its own `headers()`. The doctor says which of
|
|
643
|
+
these apply.
|
|
644
|
+
|
|
645
|
+
## Before you deploy
|
|
646
|
+
|
|
647
|
+
Run `pnpm exec guardrail-doctor --production` to check everything below.
|
|
648
|
+
|
|
649
|
+
- **Run a patched Next.js.** Guardrail requires `next` 15.5.28 or later, or
|
|
650
|
+
16.4.1 or later — older releases have published security advisories (including
|
|
651
|
+
remote code execution and middleware bypass). Keep it current;
|
|
652
|
+
`guardrail-doctor` flags a vulnerable version.
|
|
653
|
+
- **Keep email confirmation on** in Supabase (**Authentication > Sign In /
|
|
654
|
+
Providers > Email > Confirm email**), or turn public sign-ups off. This setting
|
|
655
|
+
is what stops someone registering an invited person's address before they do
|
|
656
|
+
and claiming the invitation; with it off, Supabase marks any address confirmed
|
|
657
|
+
the moment it signs up. The `20261028000000_require_confirmed_email` migration
|
|
658
|
+
adds a database-level check that only a confirmed address can claim an
|
|
659
|
+
invitation or read its roster row — defense in depth, not a substitute. Re-run
|
|
660
|
+
`pnpm exec guardrail-init-db --push` after upgrading.
|
|
661
|
+
- **Set `GUARDRAIL_SITE_URL`** (see "Passwords, invites and resets").
|
|
662
|
+
- **Rate limiting.** Sign-in (per account from one address, per account overall,
|
|
663
|
+
and per address — so one person hammering an account can't lock its owner out),
|
|
664
|
+
"Forgot password?" (per address and per inbox),
|
|
665
|
+
the emailed-link exchange, and the admin actions that send invites/resets or
|
|
666
|
+
create uploads are limited out of the box. With the Supabase adapter and a
|
|
667
|
+
`serviceRoleKey`, the counters live in your project's `guardrail` schema
|
|
668
|
+
(`gr_rate_limits`, added by the `rate_limits` migration), so limits hold
|
|
669
|
+
across serverless instances with nothing extra to run. Re-run
|
|
670
|
+
`pnpm exec guardrail-init-db --push` after upgrading to add it; until the
|
|
671
|
+
migration is applied, limits fall back to in-memory counting (with a warning
|
|
672
|
+
in the log) rather than blocking sign-in. If the database can't be reached
|
|
673
|
+
for any other reason, the request is refused.
|
|
674
|
+
|
|
675
|
+
The defaults are deliberately generous for normal use: one account may fail 8
|
|
676
|
+
sign-ins per 15 minutes from one address (and 40 from anywhere), one address
|
|
677
|
+
30, and "Forgot password?" is limited to 5 per inbox per hour. If a team signs in
|
|
678
|
+
from one shared address, raise what you need:
|
|
679
|
+
|
|
680
|
+
```ts
|
|
681
|
+
withGuardrail(RootLayout, {
|
|
682
|
+
...supabase,
|
|
683
|
+
rateLimitRules: { "login:pair": { limit: 20 }, "login:ip": { limit: 100 } },
|
|
684
|
+
});
|
|
685
|
+
```
|
|
686
|
+
|
|
687
|
+
The names are `login:pair`, `login:account`, `login:ip`, `forgot-password:ip`,
|
|
688
|
+
`forgot-password:account`, `auth-link:ip`, `invite:user`,
|
|
689
|
+
`password-reset:user` and `upload:user`; each takes `limit` and/or
|
|
690
|
+
`windowSeconds`. Someone who is throttled can always **Forgot password?** — the
|
|
691
|
+
emailed link signs them in directly.
|
|
692
|
+
|
|
693
|
+
Without a service-role key the built-in counters are in memory only, which
|
|
694
|
+
isn't enough on serverless. Pass your own limiter instead — for example with
|
|
695
|
+
Upstash:
|
|
696
|
+
|
|
697
|
+
```ts
|
|
698
|
+
import { Ratelimit } from "@upstash/ratelimit";
|
|
699
|
+
import { Redis } from "@upstash/redis";
|
|
700
|
+
|
|
701
|
+
const redis = Redis.fromEnv();
|
|
702
|
+
const limiters = new Map<string, Ratelimit>();
|
|
703
|
+
|
|
704
|
+
withGuardrail(RootLayout, {
|
|
705
|
+
...supabase,
|
|
706
|
+
rateLimit: async ({ name, key, limit, windowSeconds }) => {
|
|
707
|
+
let rl = limiters.get(name);
|
|
708
|
+
if (!rl) {
|
|
709
|
+
rl = new Ratelimit({
|
|
710
|
+
redis,
|
|
711
|
+
limiter: Ratelimit.fixedWindow(limit, `${windowSeconds} s`),
|
|
712
|
+
prefix: `guardrail:${name}`,
|
|
713
|
+
});
|
|
714
|
+
limiters.set(name, rl);
|
|
715
|
+
}
|
|
716
|
+
const { success, reset } = await rl.limit(key);
|
|
717
|
+
return { allowed: success, retryAfterSeconds: Math.ceil((reset - Date.now()) / 1000) };
|
|
718
|
+
},
|
|
719
|
+
});
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
Address-keyed limits read `X-Forwarded-For`, which is only trustworthy behind a
|
|
723
|
+
proxy that overwrites it (Vercel, Cloudflare and most load balancers do). On a
|
|
724
|
+
server reached directly, or behind one that only appends to the header, a client
|
|
725
|
+
can write it themselves — pass a `clientIp` function to read the right header
|
|
726
|
+
instead (it receives the request's `Headers`):
|
|
727
|
+
|
|
728
|
+
```ts
|
|
729
|
+
withGuardrail(RootLayout, {
|
|
730
|
+
...supabase,
|
|
731
|
+
clientIp: (headers) => headers.get("cf-connecting-ip") ?? undefined,
|
|
732
|
+
});
|
|
733
|
+
```
|
|
734
|
+
|
|
735
|
+
The per-account limits don't depend on it. Also set a spend cap in your
|
|
736
|
+
Resend dashboard as the backstop.
|
|
737
|
+
- **Add security headers** in `next.config`: at least a `Content-Security-Policy`
|
|
738
|
+
with `frame-ancestors 'self'` (so the editing chrome can't be framed by another
|
|
739
|
+
site), `Strict-Transport-Security`, and `X-Content-Type-Options: nosniff`.
|
|
740
|
+
|
|
602
741
|
## Known limitations
|
|
603
742
|
|
|
743
|
+
- **Cache Components isn't supported.** Next's `cacheComponents: true` refuses
|
|
744
|
+
routes that read cookies outside `<Suspense>`, and Guardrail reads the session
|
|
745
|
+
cookie to decide what to render. Leave it off (some newer `create-next-app`
|
|
746
|
+
templates turn it on — remove `cacheComponents` and `partialPrefetching` from
|
|
747
|
+
`next.config`; `guardrail-doctor --fix` does it for you).
|
|
604
748
|
- `withGuardrail` reads cookies in the root layout, which opts routes into
|
|
605
749
|
dynamic rendering. See the repo README for the plan.
|
|
606
750
|
- 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.
|
|
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,
|
|
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;
|
|
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
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
20
|
-
|
|
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(
|
|
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
|
|
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;
|
|
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 };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { adminClient, assertCredentials } from "./client";
|
|
2
|
+
import { GR_SCHEMA } from "../../core/defs";
|
|
3
|
+
import { memoryLimiter } from "../../server/rateLimit";
|
|
4
|
+
function supabaseRateLimit(creds) {
|
|
5
|
+
assertCredentials(creds);
|
|
6
|
+
if (!creds.serviceRoleKey) return void 0;
|
|
7
|
+
let warned = false;
|
|
8
|
+
return async ({ name, key, limit, windowSeconds }) => {
|
|
9
|
+
const { data, error } = await adminClient(creds).schema(GR_SCHEMA).rpc("gr_rate_hit", {
|
|
10
|
+
p_name: name,
|
|
11
|
+
p_key: key,
|
|
12
|
+
p_limit: limit,
|
|
13
|
+
p_window_seconds: windowSeconds
|
|
14
|
+
});
|
|
15
|
+
if (error) {
|
|
16
|
+
if (error.code === "42883" || error.code === "PGRST202") {
|
|
17
|
+
if (!warned) {
|
|
18
|
+
warned = true;
|
|
19
|
+
console.warn(
|
|
20
|
+
"Guardrail (Supabase): the rate-limit function isn't installed, so limits are counted in memory. Run `pnpm exec guardrail-init-db --push` to add it."
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
return memoryLimiter({ name, key, limit, windowSeconds });
|
|
24
|
+
}
|
|
25
|
+
throw new Error(error.message);
|
|
26
|
+
}
|
|
27
|
+
const row = Array.isArray(data) ? data[0] : data;
|
|
28
|
+
return {
|
|
29
|
+
allowed: Boolean(row?.allowed),
|
|
30
|
+
retryAfterSeconds: Number(row?.retry_after_seconds) || void 0
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export {
|
|
35
|
+
supabaseRateLimit
|
|
36
|
+
};
|
|
37
|
+
//# sourceMappingURL=rateLimit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/adapters/supabase/rateLimit.ts"],"sourcesContent":["import { adminClient, assertCredentials, type SupabaseCredentials } from \"./client\";\nimport { GR_SCHEMA } from \"../../core/defs\";\nimport { memoryLimiter } from \"../../server/rateLimit\";\nimport type { GRRateLimiter } from \"../../core/adapter-types\";\n\n/**\n * Rate-limit counters in the project's own `guardrail` schema (see the\n * rate_limits migration), so a site gets limits that hold across serverless\n * instances with nothing extra to run. One database round trip per counted\n * attempt — slower than Redis, and fine at the rate people sign in.\n *\n * Needs the service-role key, because sign-in and \"Forgot password?\" have no\n * session to carry the call. Used automatically by supabaseAdapters() when\n * that key is present.\n *\n * Failure handling: if the migration hasn't been applied yet (the function\n * doesn't exist), this warns once and counts in memory instead — an upgrade\n * that forgot the migration shouldn't lock everyone out of signing in. Any\n * other database error throws, which blocks the request (fail closed).\n */\nexport function supabaseRateLimit(creds: SupabaseCredentials): GRRateLimiter | undefined {\n assertCredentials(creds);\n if (!creds.serviceRoleKey) return undefined;\n\n let warned = false;\n return async ({ name, key, limit, windowSeconds }) => {\n const { data, error } = await adminClient(creds)\n .schema(GR_SCHEMA)\n .rpc(\"gr_rate_hit\", {\n p_name: name,\n p_key: key,\n p_limit: limit,\n p_window_seconds: windowSeconds,\n });\n\n if (error) {\n // 42883 = function doesn't exist; PGRST202 = PostgREST can't find it.\n if (error.code === \"42883\" || error.code === \"PGRST202\") {\n if (!warned) {\n warned = true;\n console.warn(\n \"Guardrail (Supabase): the rate-limit function isn't installed, so limits are counted in memory. Run `pnpm exec guardrail-init-db --push` to add it.\",\n );\n }\n return memoryLimiter({ name, key, limit, windowSeconds });\n }\n throw new Error(error.message);\n }\n\n const row = Array.isArray(data) ? data[0] : data;\n return {\n allowed: Boolean(row?.allowed),\n retryAfterSeconds: Number(row?.retry_after_seconds) || undefined,\n };\n };\n}\n"],"mappings":"AAAA,SAAS,aAAa,yBAAmD;AACzE,SAAS,iBAAiB;AAC1B,SAAS,qBAAqB;AAkBvB,SAAS,kBAAkB,OAAuD;AACvF,oBAAkB,KAAK;AACvB,MAAI,CAAC,MAAM,eAAgB,QAAO;AAElC,MAAI,SAAS;AACb,SAAO,OAAO,EAAE,MAAM,KAAK,OAAO,cAAc,MAAM;AACpD,UAAM,EAAE,MAAM,MAAM,IAAI,MAAM,YAAY,KAAK,EAC5C,OAAO,SAAS,EAChB,IAAI,eAAe;AAAA,MAClB,QAAQ;AAAA,MACR,OAAO;AAAA,MACP,SAAS;AAAA,MACT,kBAAkB;AAAA,IACpB,CAAC;AAEH,QAAI,OAAO;AAET,UAAI,MAAM,SAAS,WAAW,MAAM,SAAS,YAAY;AACvD,YAAI,CAAC,QAAQ;AACX,mBAAS;AACT,kBAAQ;AAAA,YACN;AAAA,UACF;AAAA,QACF;AACA,eAAO,cAAc,EAAE,MAAM,KAAK,OAAO,cAAc,CAAC;AAAA,MAC1D;AACA,YAAM,IAAI,MAAM,MAAM,OAAO;AAAA,IAC/B;AAEA,UAAM,MAAM,MAAM,QAAQ,IAAI,IAAI,KAAK,CAAC,IAAI;AAC5C,WAAO;AAAA,MACL,SAAS,QAAQ,KAAK,OAAO;AAAA,MAC7B,mBAAmB,OAAO,KAAK,mBAAmB,KAAK;AAAA,IACzD;AAAA,EACF;AACF;","names":[]}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|