@guard-rail/core 0.8.0 → 0.9.1
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 +76 -104
- package/dist/adapters/supabase/auth.d.ts +9 -15
- package/dist/adapters/supabase/auth.js +33 -42
- package/dist/adapters/supabase/auth.js.map +1 -1
- package/dist/adapters/supabase/confirm.js +2 -2
- package/dist/adapters/supabase/confirm.js.map +1 -1
- package/dist/adapters/supabase/members.js +13 -2
- package/dist/adapters/supabase/members.js.map +1 -1
- package/dist/core/adapter-types.d.ts +60 -28
- package/dist/core/config.d.ts +2 -1
- package/dist/core/config.js +4 -0
- package/dist/core/config.js.map +1 -1
- package/dist/core/defs.d.ts +11 -1
- package/dist/core/defs.js +11 -1
- package/dist/core/defs.js.map +1 -1
- package/dist/core/types.d.ts +6 -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/email/resend.d.ts +39 -10
- package/dist/email/resend.js +92 -49
- package/dist/email/resend.js.map +1 -1
- package/dist/index.client.d.ts +6 -4
- package/dist/index.client.js +14 -0
- package/dist/index.client.js.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/react/Admin.js +105 -107
- package/dist/react/Admin.js.map +1 -1
- package/dist/react/AuthDrawer.d.ts +23 -0
- package/dist/react/AuthDrawer.js +165 -0
- package/dist/react/AuthDrawer.js.map +1 -0
- package/dist/react/AuthRedirectSync.js +1 -1
- package/dist/react/AuthRedirectSync.js.map +1 -1
- package/dist/react/Icons.d.ts +2 -1
- package/dist/react/Icons.js +2 -0
- package/dist/react/Icons.js.map +1 -1
- package/dist/react/Team.d.ts +7 -1
- package/dist/react/Team.js +23 -3
- package/dist/react/Team.js.map +1 -1
- package/dist/server/access.d.ts +3 -1
- package/dist/server/access.js +5 -3
- package/dist/server/access.js.map +1 -1
- package/dist/server/actions.d.ts +25 -4
- package/dist/server/actions.js +115 -39
- package/dist/server/actions.js.map +1 -1
- package/dist/server/wrapper.js +2 -1
- package/dist/server/wrapper.js.map +1 -1
- package/package.json +9 -11
- package/scripts/postinstall.mjs +2 -2
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
|
|
@@ -206,93 +206,18 @@ Membership is Guardrail's own table, not the auth provider's, so the identity
|
|
|
206
206
|
vendor and the database vendor never have to be the same. Invites are recorded
|
|
207
207
|
against an email address and claimed when that person first signs in.
|
|
208
208
|
|
|
209
|
-
|
|
209
|
+
### Passwords, invites and resets
|
|
210
210
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
},
|
|
216
|
-
});
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
**Invite emails (Supabase).** Inviting someone emails them a sign-in link
|
|
220
|
-
that creates their account on first use — this works out of the box, no
|
|
221
|
-
route or email template changes needed. A small always-mounted component
|
|
222
|
-
notices the link's tokens (in the URL hash, a `code`, or a `token_hash` —
|
|
223
|
-
whichever style your project's templates happen to produce, including the
|
|
224
|
-
unedited default) and completes the session automatically.
|
|
225
|
-
|
|
226
|
-
Someone arriving this way for the first time has a valid session but no
|
|
227
|
-
password yet, so they get a **Set Password** prompt in place of the usual
|
|
228
|
-
toolbar — skippable, since a magic link works fine on its own indefinitely if
|
|
229
|
-
they'd rather not. The same prompt appears after a password-recovery link.
|
|
230
|
-
Someone who already had an account (from an earlier invite, or from another
|
|
231
|
-
site on the same Supabase project) is just signed in: auth users are
|
|
232
|
-
project-wide, so a second prompt would have them overwrite a password they use
|
|
233
|
-
everywhere.
|
|
234
|
-
|
|
235
|
-
Two things still worth knowing:
|
|
236
|
-
|
|
237
|
-
- **Auth → URL Configuration**, in the Supabase dashboard: Site URL and
|
|
238
|
-
Redirect URLs must actually match wherever your app runs, including in
|
|
239
|
-
local dev — a mismatch here makes Supabase silently redirect to whatever
|
|
240
|
-
Site URL *is* configured instead of erroring, which reads exactly like the
|
|
241
|
-
link "not working" for no visible reason.
|
|
242
|
-
- **Auth → SMTP**: Supabase's built-in mailer allows only a few emails an
|
|
243
|
-
hour. Configure custom SMTP (Resend, Postmark, SES) before relying on
|
|
244
|
-
invites. Submitting the same address again re-sends a pending invite.
|
|
245
|
-
|
|
246
|
-
Prefer a route that verifies server-side rather than relying on client JS to
|
|
247
|
-
notice the redirect? `supabaseConfirmRoute` still works exactly as before —
|
|
248
|
-
mount it and point a custom email template's link at
|
|
249
|
-
`{{ .SiteURL }}/guardrail/confirm?token_hash={{ .TokenHash }}&type=email`:
|
|
250
|
-
|
|
251
|
-
```ts
|
|
252
|
-
// app/guardrail/confirm/route.ts
|
|
253
|
-
export const GET = supabaseConfirmRoute({ url, anonKey });
|
|
254
|
-
```
|
|
255
|
-
|
|
256
|
-
The default `{{ .ConfirmationURL }}` uses a PKCE code that only works in the
|
|
257
|
-
browser that requested it — the admin's, not the invitee's — so a custom
|
|
258
|
-
template needs the token hash form above either way. Arriving through this
|
|
259
|
-
route shows the same **Set Password** prompt as the default flow.
|
|
260
|
-
|
|
261
|
-
**Customizing the invite email.** Guardrail sends invites as Supabase *magic
|
|
262
|
-
link* emails, so the wording is Supabase's **Magic Link** template — and with
|
|
263
|
-
the Supabase CLI it can live in your repo instead of the dashboard. Declare it
|
|
264
|
-
in `supabase/config.toml`, then push it:
|
|
265
|
-
|
|
266
|
-
```toml
|
|
267
|
-
[auth.email.template.magic_link]
|
|
268
|
-
subject = "You've been invited"
|
|
269
|
-
content_path = "./supabase/templates/magic_link.html"
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
```html
|
|
273
|
-
<!-- supabase/templates/magic_link.html -->
|
|
274
|
-
<h2>You've been invited</h2>
|
|
275
|
-
<p><a href="{{ .SiteURL }}/guardrail/confirm?token_hash={{ .TokenHash }}&type=email">Set up your account</a></p>
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
```bash
|
|
279
|
-
supabase config diff # preview
|
|
280
|
-
supabase config push # apply to the linked project
|
|
281
|
-
```
|
|
282
|
-
|
|
283
|
-
Templates belong to the Supabase *project*, so sites sharing one project share
|
|
284
|
-
this wording — and `supabase config push` writes the project's auth settings,
|
|
285
|
-
so don't run it against a project other sites depend on. To make the email
|
|
286
|
-
entirely your own instead, send it yourself:
|
|
211
|
+
Guardrail signs people in with an email and a **password**, and nothing else — no
|
|
212
|
+
magic links, no OAuth. An emailed link only ever lets someone *choose* a
|
|
213
|
+
password; it never signs them in by itself. When a session expires, they type
|
|
214
|
+
their password.
|
|
287
215
|
|
|
288
|
-
|
|
289
|
-
key and an `onInvite` handler, and Guardrail creates the account and mints the
|
|
290
|
-
sign-in link while *your* site sends the email — your domain, your template, no
|
|
291
|
-
dependence on the project's templates, Site URL, or redirect allowlist:
|
|
216
|
+
Set it up once, and everything that sends email works:
|
|
292
217
|
|
|
293
218
|
```ts
|
|
294
219
|
// lib/guardrail.ts
|
|
295
|
-
import {
|
|
220
|
+
import { resendEmails } from "@guard-rail/core";
|
|
296
221
|
import { supabaseAdapters } from "@guard-rail/core/adapters/supabase";
|
|
297
222
|
|
|
298
223
|
const supabase = supabaseAdapters({
|
|
@@ -304,7 +229,7 @@ const supabase = supabaseAdapters({
|
|
|
304
229
|
withGuardrail(RootLayout, {
|
|
305
230
|
...supabase,
|
|
306
231
|
siteId: "my-site",
|
|
307
|
-
|
|
232
|
+
...resendEmails({
|
|
308
233
|
from: "My Site <hello@mysite.com>", // a domain verified in Resend
|
|
309
234
|
siteName: "My Site",
|
|
310
235
|
// apiKey defaults to process.env.RESEND_API_KEY
|
|
@@ -312,31 +237,75 @@ withGuardrail(RootLayout, {
|
|
|
312
237
|
});
|
|
313
238
|
```
|
|
314
239
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
240
|
+
Guardrail creates the account and a one-time link, and *your* site sends the
|
|
241
|
+
email, from your domain. The link points at your own site, not through Supabase,
|
|
242
|
+
so there is no Site URL, redirect allowlist, or email template to configure.
|
|
243
|
+
|
|
244
|
+
| What happens | The email | After the link |
|
|
245
|
+
|---|---|---|
|
|
246
|
+
| An admin invites a new person | "You're invited" — **Set your password** | A Set Password drawer, then they're in |
|
|
247
|
+
| The address already has an account | "You've been added" — opens the site | No link to follow: they sign in with the password they have |
|
|
248
|
+
| Someone clicks **Forgot password?** on the sign-in bar | "Reset your password" | A Set Password drawer |
|
|
249
|
+
| An admin clicks the key icon beside a teammate in the Team drawer | The same reset email | The same |
|
|
250
|
+
|
|
251
|
+
Worth knowing:
|
|
252
|
+
|
|
253
|
+
- **Setting a password can't be skipped.** It's the only way back in once a
|
|
254
|
+
session ends, so the drawer has no dismiss button — only "Cancel and sign out".
|
|
255
|
+
- **Links work once.** A link that has expired or been used shows "That link
|
|
256
|
+
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.
|
|
262
|
+
- **"Forgot password?" answers the same whether or not the address belongs to
|
|
263
|
+
anyone**, takes the same time, and only sends to members — never to a stranger.
|
|
264
|
+
It sends at most one email per address per minute.
|
|
265
|
+
- **A site that isn't set up says so.** With no service-role key or no email
|
|
266
|
+
handler, the admin sees what to add (the invite itself is still saved), rather
|
|
267
|
+
than the email quietly not arriving.
|
|
268
|
+
|
|
269
|
+
The service-role key is project-wide by Supabase's design — it bypasses RLS and
|
|
270
|
+
reaches every schema, not just Guardrail's — so keep it in a server-only
|
|
271
|
+
environment variable (never `NEXT_PUBLIC_`). Guardrail uses it only to create
|
|
272
|
+
accounts and links, and to check membership for "Forgot password?". The Resend
|
|
273
|
+
key belongs in the site's server environment too; a *sending-only* key limited
|
|
274
|
+
to your domain is plenty.
|
|
275
|
+
|
|
276
|
+
**While developing,** you don't need a mail provider: `consoleEmails()` prints
|
|
277
|
+
each invite and reset link to your terminal instead of sending it, so the whole
|
|
278
|
+
flow can be tried locally. It refuses in production — a link is as good as a
|
|
279
|
+
password for choosing one, so it doesn't belong in a log — and tells the admin
|
|
280
|
+
what to configure instead:
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
...(process.env.GUARDRAIL_EMAIL_FROM
|
|
284
|
+
? resendEmails({ from: process.env.GUARDRAIL_EMAIL_FROM, siteName: "My Site" })
|
|
285
|
+
: consoleEmails()),
|
|
286
|
+
```
|
|
321
287
|
|
|
322
|
-
|
|
323
|
-
|
|
288
|
+
**Your own wording or provider.** `render` and `renderReset` replace the default
|
|
289
|
+
emails; any other provider just needs an `onInvite` and `onPasswordReset` — each
|
|
290
|
+
receives the same event, including the `link`:
|
|
324
291
|
|
|
325
292
|
```ts
|
|
326
|
-
|
|
293
|
+
...resendEmails({
|
|
327
294
|
from: "My Site <hello@mysite.com>",
|
|
328
295
|
render: ({ link, siteName, isNewUser }) => ({
|
|
329
296
|
subject: `Welcome to ${siteName}`,
|
|
330
|
-
html: `<a href="${link}">${isNewUser ? "Set
|
|
297
|
+
html: `<a href="${link}">${isNewUser ? "Set your password" : "Sign in"}</a>`,
|
|
331
298
|
}),
|
|
332
|
-
})
|
|
333
|
-
|
|
299
|
+
renderReset: ({ link, siteName }) => ({
|
|
300
|
+
subject: `Reset your ${siteName} password`,
|
|
301
|
+
html: `<a href="${link}">Choose a new password</a>`,
|
|
302
|
+
}),
|
|
303
|
+
}),
|
|
334
304
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
magic-link email as before; both settings are needed to opt in.
|
|
305
|
+
// or, with any mail provider:
|
|
306
|
+
onInvite: async ({ email, role, siteId, invitedBy, link, isNewUser }) => { /* send it */ },
|
|
307
|
+
onPasswordReset: async ({ email, siteId, link, requestedBy }) => { /* send it */ },
|
|
308
|
+
```
|
|
340
309
|
|
|
341
310
|
**Bootstrapping.** A site with no members has nobody who can add members, and
|
|
342
311
|
there is deliberately no in-app way to grant yourself a role — that would mean
|
|
@@ -345,7 +314,7 @@ as letting anyone make themselves an admin. Seed the first admin with the
|
|
|
345
314
|
bundled command instead of hand-writing SQL:
|
|
346
315
|
|
|
347
316
|
```bash
|
|
348
|
-
SERVICE_KEY=...
|
|
317
|
+
SERVICE_KEY=... pnpm exec guardrail-seed-user --email=you@example.com --password=...
|
|
349
318
|
```
|
|
350
319
|
|
|
351
320
|
`--site-id` defaults to `GUARDRAIL_SITE_ID` from your `.env.local` (and
|
|
@@ -600,8 +569,11 @@ an account with no role on the site — the toolbar's normal exit button ends ed
|
|
|
600
569
|
keeps the session, so it doesn't call it). `authenticate` is optional —
|
|
601
570
|
adapters built on magic links, SSO, or a hosted sign-in page omit it and set
|
|
602
571
|
`signInUrl`, and Guardrail links there instead of rendering a password form it
|
|
603
|
-
cannot fulfil. `
|
|
604
|
-
|
|
572
|
+
cannot fulfil. `createInviteLink` and `createPasswordResetLink` are optional
|
|
573
|
+
too: they create the account and a one-time link without sending anything, and
|
|
574
|
+
Guardrail hands the link to your `onInvite` / `onPasswordReset`. An adapter
|
|
575
|
+
without them leaves delivery entirely to those handlers, which then get no
|
|
576
|
+
`link`.
|
|
605
577
|
|
|
606
578
|
```ts
|
|
607
579
|
const clerkAuth: GRAuthAdapter = {
|
|
@@ -5,23 +5,17 @@ import 'react';
|
|
|
5
5
|
import '../../core/types.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Whether
|
|
8
|
+
* Whether an emailed link should land on the Set Password step.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* sites: auth users are project-wide, so they have one password everywhere,
|
|
18
|
-
* and a prompt on a second site would have them overwrite it.
|
|
19
|
-
*
|
|
20
|
-
* Skippable either way — a magic link keeps working without a password.
|
|
10
|
+
* Decided by the link's own type, which Guardrail's emails always carry, and
|
|
11
|
+
* by nothing inferred about the account: "invite" and "signup" are someone's
|
|
12
|
+
* first link into a brand-new account, and "recovery" is a password reset. All
|
|
13
|
+
* three exist to let a person choose a password. Anything else — an old magic
|
|
14
|
+
* link, a PKCE code that carries no type — just signs in, as Supabase intends.
|
|
15
|
+
* Passwords are the only credential Guardrail asks people to use; these links
|
|
16
|
+
* exist only to set one.
|
|
21
17
|
*/
|
|
22
|
-
declare function needsPasswordFor(type: string | null | undefined
|
|
23
|
-
email_confirmed_at?: string | null;
|
|
24
|
-
} | null | undefined): boolean;
|
|
18
|
+
declare function needsPasswordFor(type: string | null | undefined): boolean;
|
|
25
19
|
declare function supabaseAuth(creds: SupabaseCredentials): GRAuthAdapter;
|
|
26
20
|
|
|
27
21
|
export { needsPasswordFor, supabaseAuth };
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { adminClient,
|
|
2
|
-
function needsPasswordFor(type
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { adminClient, assertCredentials, sessionClient } from "./client";
|
|
2
|
+
function needsPasswordFor(type) {
|
|
3
|
+
return type === "invite" || type === "signup" || type === "recovery";
|
|
4
|
+
}
|
|
5
|
+
function isExistingAccountError(error) {
|
|
6
|
+
return error.code === "email_exists" || error.code === "user_already_exists" || /already (been )?registered|already exists/i.test(error.message);
|
|
7
|
+
}
|
|
8
|
+
function tokenLink(origin, tokenHash, type) {
|
|
9
|
+
const link = new URL("/", origin);
|
|
10
|
+
link.searchParams.set("token_hash", tokenHash);
|
|
11
|
+
link.searchParams.set("type", type);
|
|
12
|
+
return link.toString();
|
|
6
13
|
}
|
|
7
|
-
const JUST_CONFIRMED_MS = 2 * 60 * 1e3;
|
|
8
14
|
function supabaseAuth(creds) {
|
|
9
15
|
assertCredentials(creds);
|
|
10
16
|
const adapter = {
|
|
@@ -32,25 +38,6 @@ function supabaseAuth(creds) {
|
|
|
32
38
|
if (error || !data.user) return null;
|
|
33
39
|
return { userId: data.user.id, email: data.user.email };
|
|
34
40
|
},
|
|
35
|
-
/**
|
|
36
|
-
* Sends a sign-in link that creates the account on first use.
|
|
37
|
-
*
|
|
38
|
-
* Requested through the cookie-free client on purpose. A session client
|
|
39
|
-
* would use PKCE and drop the code verifier into the *admin's* browser,
|
|
40
|
-
* so the link would fail when the invitee opened it anywhere else. The
|
|
41
|
-
* email template must point at the confirm route with {{ .TokenHash }},
|
|
42
|
-
* which verifies server-side and needs no verifier.
|
|
43
|
-
*/
|
|
44
|
-
async inviteUser(email, options) {
|
|
45
|
-
const { error } = await anonClient(creds).auth.signInWithOtp({
|
|
46
|
-
email,
|
|
47
|
-
options: {
|
|
48
|
-
shouldCreateUser: true,
|
|
49
|
-
...options?.redirectTo ? { emailRedirectTo: options.redirectTo } : {}
|
|
50
|
-
}
|
|
51
|
-
});
|
|
52
|
-
return error ? { success: false, error: error.message } : { success: true };
|
|
53
|
-
},
|
|
54
41
|
async logout() {
|
|
55
42
|
const client = await sessionClient(creds);
|
|
56
43
|
await client.auth.signOut();
|
|
@@ -80,22 +67,22 @@ function supabaseAuth(creds) {
|
|
|
80
67
|
if (!access_token || !refresh_token) {
|
|
81
68
|
return { success: false, error: "Redirect link is missing its tokens." };
|
|
82
69
|
}
|
|
83
|
-
const {
|
|
70
|
+
const { error } = await client.auth.setSession({ access_token, refresh_token });
|
|
84
71
|
if (error) return { success: false, error: error.message };
|
|
85
|
-
return { success: true, needsPassword: needsPasswordFor(hashType
|
|
72
|
+
return { success: true, needsPassword: needsPasswordFor(hashType) };
|
|
86
73
|
}
|
|
87
74
|
if (code) {
|
|
88
|
-
const {
|
|
75
|
+
const { error } = await client.auth.exchangeCodeForSession(code);
|
|
89
76
|
if (error) return { success: false, error: error.message };
|
|
90
|
-
return { success: true, needsPassword: needsPasswordFor(type
|
|
77
|
+
return { success: true, needsPassword: needsPasswordFor(type) };
|
|
91
78
|
}
|
|
92
79
|
if (tokenHash && type) {
|
|
93
|
-
const {
|
|
80
|
+
const { error } = await client.auth.verifyOtp({
|
|
94
81
|
type,
|
|
95
82
|
token_hash: tokenHash
|
|
96
83
|
});
|
|
97
84
|
if (error) return { success: false, error: error.message };
|
|
98
|
-
return { success: true, needsPassword: needsPasswordFor(type
|
|
85
|
+
return { success: true, needsPassword: needsPasswordFor(type) };
|
|
99
86
|
}
|
|
100
87
|
return { success: false, error: "No redirect data to complete." };
|
|
101
88
|
},
|
|
@@ -108,20 +95,24 @@ function supabaseAuth(creds) {
|
|
|
108
95
|
if (creds.serviceRoleKey) {
|
|
109
96
|
adapter.createInviteLink = async (email, { origin }) => {
|
|
110
97
|
const admin = adminClient(creds);
|
|
111
|
-
|
|
112
|
-
let res = await admin.auth.admin.generateLink({ type: "invite", email });
|
|
98
|
+
const res = await admin.auth.admin.generateLink({ type: "invite", email });
|
|
113
99
|
if (res.error) {
|
|
114
|
-
|
|
115
|
-
|
|
100
|
+
if (isExistingAccountError(res.error)) {
|
|
101
|
+
return { success: true, link: new URL("/?edit=true", origin).toString(), isNewUser: false };
|
|
102
|
+
}
|
|
103
|
+
return { success: false, error: res.error.message };
|
|
116
104
|
}
|
|
117
|
-
|
|
118
|
-
|
|
105
|
+
const hashed = res.data?.properties?.hashed_token;
|
|
106
|
+
if (!hashed) return { success: false, error: "No invite link was returned." };
|
|
107
|
+
return { success: true, link: tokenLink(origin, hashed, "invite"), isNewUser: true };
|
|
108
|
+
};
|
|
109
|
+
adapter.createPasswordResetLink = async (email, { origin }) => {
|
|
110
|
+
const res = await adminClient(creds).auth.admin.generateLink({ type: "recovery", email });
|
|
111
|
+
const hashed = res.data?.properties?.hashed_token;
|
|
112
|
+
if (res.error || !hashed) {
|
|
113
|
+
return { success: false, error: res.error?.message ?? "No reset link was returned." };
|
|
119
114
|
}
|
|
120
|
-
|
|
121
|
-
const link = new URL("/", origin);
|
|
122
|
-
link.searchParams.set("token_hash", hashed_token);
|
|
123
|
-
link.searchParams.set("type", verification_type);
|
|
124
|
-
return { success: true, link: link.toString(), isNewUser };
|
|
115
|
+
return { success: true, link: tokenLink(origin, hashed, "recovery") };
|
|
125
116
|
};
|
|
126
117
|
}
|
|
127
118
|
return adapter;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/adapters/supabase/auth.ts"],"sourcesContent":["import type { EmailOtpType } from \"@supabase/supabase-js\";\nimport { adminClient, anonClient, 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 this link signs in someone who still needs to choose a password.\n *\n * \"invite\" and \"recovery\" always do: one is a brand-new account, the other is\n * someone who asked to reset theirs. Every other kind — a magic link, its\n * token_hash form (\"email\"), a PKCE code with no type at all — is decided by\n * the account instead: it needs a password if this very link is what confirmed\n * its email address, i.e. a first sign-in. That is what \"an invite sent as a\n * magic link\" looks like, and it deliberately leaves out someone who already\n * has an account, which matters most on a Supabase project shared by several\n * sites: auth users are project-wide, so they have one password everywhere,\n * and a prompt on a second site would have them overwrite it.\n *\n * Skippable either way — a magic link keeps working without a password.\n */\nexport function needsPasswordFor(\n type: string | null | undefined,\n user: { email_confirmed_at?: string | null } | null | undefined,\n): boolean {\n if (type === \"invite\" || type === \"recovery\") return true;\n const confirmedAt = user?.email_confirmed_at ? Date.parse(user.email_confirmed_at) : NaN;\n return Number.isFinite(confirmedAt) && Date.now() - confirmedAt < JUST_CONFIRMED_MS;\n}\n\n/**\n * \"Just now\" for a first sign-in. Supabase stamps email_confirmed_at when the\n * link is clicked, not when it's sent, so this only has to span the seconds\n * between that click and this check — a longer window would just start\n * catching accounts created or confirmed a few minutes earlier.\n */\nconst JUST_CONFIRMED_MS = 2 * 60 * 1000;\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 /**\n * Sends a sign-in link that creates the account on first use.\n *\n * Requested through the cookie-free client on purpose. A session client\n * would use PKCE and drop the code verifier into the *admin's* browser,\n * so the link would fail when the invitee opened it anywhere else. The\n * email template must point at the confirm route with {{ .TokenHash }},\n * which verifies server-side and needs no verifier.\n */\n async inviteUser(email, options) {\n const { error } = await anonClient(creds).auth.signInWithOtp({\n email,\n options: {\n shouldCreateUser: true,\n ...(options?.redirectTo ? { emailRedirectTo: options.redirectTo } : {}),\n },\n });\n return error\n ? { success: false, error: error.message }\n : { success: true };\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 { data, 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, data.user) };\n }\n\n if (code) {\n const { data, error } = await client.auth.exchangeCodeForSession(code);\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: needsPasswordFor(type, data.user) };\n }\n\n if (tokenHash && type) {\n const { data, 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, data.user) };\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 (Guardrail then falls back to inviteUser).\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 // A new address gets a real invite; one that already has an account (here\n // or on another site on the same project) gets a sign-in link instead.\n let isNewUser = true;\n let res = await admin.auth.admin.generateLink({ type: \"invite\", email });\n if (res.error) {\n isNewUser = false;\n res = await admin.auth.admin.generateLink({ type: \"magiclink\", email });\n }\n if (res.error || !res.data?.properties?.hashed_token) {\n return { success: false, error: res.error?.message ?? \"No sign-in link was returned.\" };\n }\n\n const { hashed_token, verification_type } = res.data.properties;\n const link = new URL(\"/\", origin);\n link.searchParams.set(\"token_hash\", hashed_token);\n link.searchParams.set(\"type\", verification_type);\n return { success: true, link: link.toString(), isNewUser };\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 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":[]}
|
|
@@ -17,9 +17,9 @@ function supabaseConfirmRoute(creds, options = {}) {
|
|
|
17
17
|
const next = safePath(url.searchParams.get("next"), fallback);
|
|
18
18
|
if (tokenHash && type) {
|
|
19
19
|
const client = await sessionClient(creds);
|
|
20
|
-
const {
|
|
20
|
+
const { error } = await client.auth.verifyOtp({ type, token_hash: tokenHash });
|
|
21
21
|
if (!error) {
|
|
22
|
-
if (needsPasswordFor(type
|
|
22
|
+
if (needsPasswordFor(type)) {
|
|
23
23
|
(await cookies()).set(GR_COOKIES.NEEDS_PASSWORD, "true", {
|
|
24
24
|
path: "/",
|
|
25
25
|
maxAge: GR_NEEDS_PASSWORD_TTL_SECONDS
|
|
@@ -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 {
|
|
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,4 +1,4 @@
|
|
|
1
|
-
import { assertCredentials, sessionClient } from "./client";
|
|
1
|
+
import { adminClient, assertCredentials, sessionClient } from "./client";
|
|
2
2
|
import { GR_MEMBERS_TABLE, GR_SCHEMA } from "../../core/defs";
|
|
3
3
|
function toMember(row) {
|
|
4
4
|
return {
|
|
@@ -12,7 +12,7 @@ function toMember(row) {
|
|
|
12
12
|
const COLUMNS = "user_id, email, role, status, invited_at";
|
|
13
13
|
function supabaseMembers(creds) {
|
|
14
14
|
assertCredentials(creds);
|
|
15
|
-
|
|
15
|
+
const adapter = {
|
|
16
16
|
async getMember(siteId, lookup) {
|
|
17
17
|
const client = await sessionClient(creds);
|
|
18
18
|
let query = client.schema(GR_SCHEMA).from(GR_MEMBERS_TABLE).select(COLUMNS).eq("site_id", siteId);
|
|
@@ -66,6 +66,17 @@ function supabaseMembers(creds) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
};
|
|
69
|
+
if (creds.serviceRoleKey) {
|
|
70
|
+
adapter.emailHasAccess = async (siteId, email) => {
|
|
71
|
+
const { data, error } = await adminClient(creds).schema(GR_SCHEMA).from(GR_MEMBERS_TABLE).select("email").eq("site_id", siteId).eq("email", email.toLowerCase().trim()).maybeSingle();
|
|
72
|
+
if (error) {
|
|
73
|
+
console.error("Guardrail (Supabase): membership check failed", error.message);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return data !== null;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return adapter;
|
|
69
80
|
}
|
|
70
81
|
export {
|
|
71
82
|
supabaseMembers
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/adapters/supabase/members.ts"],"sourcesContent":["import { assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport { GR_MEMBERS_TABLE, GR_SCHEMA } from \"../../core/defs\";\nimport type { GRMembershipAdapter } from \"../../core/adapter-types\";\nimport type { GRMember, GRRole } from \"../../core/types\";\n\ninterface MemberRow {\n user_id: string | null;\n email: string;\n role: GRRole;\n status: \"active\" | \"pending\";\n invited_at: string | null;\n}\n\nfunction toMember(row: MemberRow): GRMember {\n return {\n userId: row.user_id,\n email: row.email,\n role: row.role,\n status: row.status,\n ...(row.invited_at ? { invitedAt: row.invited_at } : {}),\n };\n}\n\nconst COLUMNS = \"user_id, email, role, status, invited_at\";\n\nexport function supabaseMembers(\n creds: SupabaseCredentials,\n): GRMembershipAdapter {\n assertCredentials(creds);\n\n
|
|
1
|
+
{"version":3,"sources":["../../../src/adapters/supabase/members.ts"],"sourcesContent":["import { adminClient, assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport { GR_MEMBERS_TABLE, GR_SCHEMA } from \"../../core/defs\";\nimport type { GRMembershipAdapter } from \"../../core/adapter-types\";\nimport type { GRMember, GRRole } from \"../../core/types\";\n\ninterface MemberRow {\n user_id: string | null;\n email: string;\n role: GRRole;\n status: \"active\" | \"pending\";\n invited_at: string | null;\n}\n\nfunction toMember(row: MemberRow): GRMember {\n return {\n userId: row.user_id,\n email: row.email,\n role: row.role,\n status: row.status,\n ...(row.invited_at ? { invitedAt: row.invited_at } : {}),\n };\n}\n\nconst COLUMNS = \"user_id, email, role, status, invited_at\";\n\nexport function supabaseMembers(\n creds: SupabaseCredentials,\n): GRMembershipAdapter {\n assertCredentials(creds);\n\n const adapter: GRMembershipAdapter = {\n async getMember(siteId, lookup) {\n // Session-bound by necessity: RLS evaluates auth.uid(), so the anon\n // client would see nothing and every role would resolve to null.\n const client = await sessionClient(creds);\n let query = client\n .schema(GR_SCHEMA)\n .from(GR_MEMBERS_TABLE)\n .select(COLUMNS)\n .eq(\"site_id\", siteId);\n\n if (lookup.userId) query = query.eq(\"user_id\", lookup.userId);\n else if (lookup.email)\n query = query.eq(\"email\", lookup.email.toLowerCase().trim());\n else return null;\n\n const { data, error } = await query.maybeSingle();\n if (error) {\n console.error(\"Guardrail (Supabase): member lookup failed\", error.message);\n return null;\n }\n return data ? toMember(data as MemberRow) : null;\n },\n\n async listMembers(siteId) {\n const client = await sessionClient(creds);\n const { data, error } = await client\n .schema(GR_SCHEMA)\n .from(GR_MEMBERS_TABLE)\n .select(COLUMNS)\n .eq(\"site_id\", siteId)\n .order(\"email\");\n\n if (error) {\n console.error(\"Guardrail (Supabase): list members failed\", error.message);\n return [];\n }\n return ((data ?? []) as MemberRow[]).map(toMember);\n },\n\n async upsertMember(siteId, member) {\n const client = await sessionClient(creds);\n const { error } = await client\n .schema(GR_SCHEMA)\n .from(GR_MEMBERS_TABLE)\n .upsert(\n {\n site_id: siteId,\n email: member.email.toLowerCase().trim(),\n role: member.role,\n user_id: member.userId ?? null,\n status: member.status,\n invited_at: new Date().toISOString(),\n },\n { onConflict: \"site_id,email\" },\n );\n return error\n ? { success: false, error: error.message }\n : { success: true };\n },\n\n async removeMember(siteId, email) {\n const client = await sessionClient(creds);\n const { error } = await client\n .schema(GR_SCHEMA)\n .from(GR_MEMBERS_TABLE)\n .delete()\n .eq(\"site_id\", siteId)\n .eq(\"email\", email.toLowerCase().trim());\n return error\n ? { success: false, error: error.message }\n : { success: true };\n },\n\n async claimInvite(siteId) {\n const client = await sessionClient(creds);\n // SECURITY DEFINER function: sets user_id/status on a pending row\n // matching the caller's own email, and cannot alter the invited role.\n const { error } = await client.schema(GR_SCHEMA).rpc(\"gr_claim_invite\", {\n target_site: siteId,\n });\n if (error) {\n console.error(\"Guardrail (Supabase): claim failed\", error.message);\n }\n },\n };\n\n // Only with a service-role key: an unauthenticated \"forgot password\" request\n // has no session for RLS to evaluate, so telling members from strangers takes\n // privileged access to the membership table. Without the key the adapter\n // simply doesn't offer it, and Guardrail falls back to Supabase's own behavior.\n if (creds.serviceRoleKey) {\n adapter.emailHasAccess = async (siteId, email) => {\n const { data, error } = await adminClient(creds)\n .schema(GR_SCHEMA)\n .from(GR_MEMBERS_TABLE)\n .select(\"email\")\n .eq(\"site_id\", siteId)\n .eq(\"email\", email.toLowerCase().trim())\n .maybeSingle();\n if (error) {\n console.error(\"Guardrail (Supabase): membership check failed\", error.message);\n return false;\n }\n return data !== null;\n };\n }\n\n return adapter;\n}\n"],"mappings":"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","names":[]}
|