@guard-rail/core 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +62 -102
  2. package/dist/adapters/supabase/auth.d.ts +9 -15
  3. package/dist/adapters/supabase/auth.js +33 -42
  4. package/dist/adapters/supabase/auth.js.map +1 -1
  5. package/dist/adapters/supabase/confirm.js +2 -2
  6. package/dist/adapters/supabase/confirm.js.map +1 -1
  7. package/dist/adapters/supabase/members.js +13 -2
  8. package/dist/adapters/supabase/members.js.map +1 -1
  9. package/dist/core/adapter-types.d.ts +60 -28
  10. package/dist/core/config.d.ts +2 -1
  11. package/dist/core/config.js +4 -0
  12. package/dist/core/config.js.map +1 -1
  13. package/dist/core/defs.d.ts +11 -1
  14. package/dist/core/defs.js +11 -1
  15. package/dist/core/defs.js.map +1 -1
  16. package/dist/core/types.d.ts +6 -0
  17. package/dist/email/resend.d.ts +39 -10
  18. package/dist/email/resend.js +92 -49
  19. package/dist/email/resend.js.map +1 -1
  20. package/dist/index.client.d.ts +5 -4
  21. package/dist/index.client.js +9 -0
  22. package/dist/index.client.js.map +1 -1
  23. package/dist/index.d.ts +3 -3
  24. package/dist/index.js +6 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/react/Admin.js +105 -107
  27. package/dist/react/Admin.js.map +1 -1
  28. package/dist/react/AuthDrawer.d.ts +23 -0
  29. package/dist/react/AuthDrawer.js +165 -0
  30. package/dist/react/AuthDrawer.js.map +1 -0
  31. package/dist/react/AuthRedirectSync.js +1 -1
  32. package/dist/react/AuthRedirectSync.js.map +1 -1
  33. package/dist/react/Icons.d.ts +2 -1
  34. package/dist/react/Icons.js +2 -0
  35. package/dist/react/Icons.js.map +1 -1
  36. package/dist/react/Team.d.ts +7 -1
  37. package/dist/react/Team.js +23 -3
  38. package/dist/react/Team.js.map +1 -1
  39. package/dist/server/access.d.ts +3 -1
  40. package/dist/server/access.js +5 -3
  41. package/dist/server/access.js.map +1 -1
  42. package/dist/server/actions.d.ts +25 -4
  43. package/dist/server/actions.js +115 -39
  44. package/dist/server/actions.js.map +1 -1
  45. package/dist/server/wrapper.js +2 -1
  46. package/dist/server/wrapper.js.map +1 -1
  47. package/package.json +8 -10
package/README.md CHANGED
@@ -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
- Guardrail records an invite but does not send it — wire your own mail provider:
209
+ ### Passwords, invites and resets
210
210
 
211
- ```tsx
212
- export default withGuardrail(RootLayout, {
213
- onInvite: async ({ email, role, siteId, invitedBy }) => {
214
- await resend.emails.send({ to: email, /* your template */ });
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
- ```
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.
277
215
 
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:
287
-
288
- **Branded invite emails (Resend).** Give the Supabase adapter a service-role
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 { resendInvite } from "@guard-rail/core";
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
- onInvite: resendInvite({
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,63 @@ withGuardrail(RootLayout, {
312
237
  });
313
238
  ```
314
239
 
315
- New addresses get a "Set up your account" email, and addresses that already
316
- have an account get "Sign in". The link points at your own site and is
317
- completed there, so nothing needs mounting, and an email scanner that
318
- pre-fetches links can't use it up. If sending fails — Resend rejecting the
319
- sender domain, say — the admin who clicked Invite sees why, and the invite stays
320
- recorded so they can resend it.
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.
321
275
 
322
- For your own markup, pass `render`; for another provider, write your own
323
- `onInvite` — it receives the same event, including `link`:
276
+ **Your own wording or provider.** `render` and `renderReset` replace the default
277
+ emails; any other provider just needs an `onInvite` and `onPasswordReset` — each
278
+ receives the same event, including the `link`:
324
279
 
325
280
  ```ts
326
- resendInvite({
281
+ ...resendEmails({
327
282
  from: "My Site <hello@mysite.com>",
328
283
  render: ({ link, siteName, isNewUser }) => ({
329
284
  subject: `Welcome to ${siteName}`,
330
- html: `<a href="${link}">${isNewUser ? "Set up your account" : "Sign in"}</a>`,
285
+ html: `<a href="${link}">${isNewUser ? "Set your password" : "Sign in"}</a>`,
331
286
  }),
332
- });
333
- ```
287
+ renderReset: ({ link, siteName }) => ({
288
+ subject: `Reset your ${siteName} password`,
289
+ html: `<a href="${link}">Choose a new password</a>`,
290
+ }),
291
+ }),
334
292
 
335
- The service-role key is project-wide by Supabase's design — it bypasses RLS and
336
- reaches every schema, not just Guardrail's — so keep it in a server-only
337
- environment variable (never `NEXT_PUBLIC_`). It is used for exactly one call,
338
- creating the invite link. Leave it out and Guardrail sends Supabase's own
339
- magic-link email as before; both settings are needed to opt in.
293
+ // or, with any mail provider:
294
+ onInvite: async ({ email, role, siteId, invitedBy, link, isNewUser }) => { /* send it */ },
295
+ onPasswordReset: async ({ email, siteId, link, requestedBy }) => { /* send it */ },
296
+ ```
340
297
 
341
298
  **Bootstrapping.** A site with no members has nobody who can add members, and
342
299
  there is deliberately no in-app way to grant yourself a role — that would mean
@@ -600,8 +557,11 @@ an account with no role on the site — the toolbar's normal exit button ends ed
600
557
  keeps the session, so it doesn't call it). `authenticate` is optional —
601
558
  adapters built on magic links, SSO, or a hosted sign-in page omit it and set
602
559
  `signInUrl`, and Guardrail links there instead of rendering a password form it
603
- cannot fulfil. `inviteUser` is optional too; without it, invites fall back to
604
- your `onInvite` callback.
560
+ cannot fulfil. `createInviteLink` and `createPasswordResetLink` are optional
561
+ too: they create the account and a one-time link without sending anything, and
562
+ Guardrail hands the link to your `onInvite` / `onPasswordReset`. An adapter
563
+ without them leaves delivery entirely to those handlers, which then get no
564
+ `link`.
605
565
 
606
566
  ```ts
607
567
  const clerkAuth: GRAuthAdapter = {
@@ -5,23 +5,17 @@ import 'react';
5
5
  import '../../core/types.js';
6
6
 
7
7
  /**
8
- * Whether this link signs in someone who still needs to choose a password.
8
+ * Whether an emailed link should land on the Set Password step.
9
9
  *
10
- * "invite" and "recovery" always do: one is a brand-new account, the other is
11
- * someone who asked to reset theirs. Every other kind — a magic link, its
12
- * token_hash form ("email"), a PKCE code with no type at all — is decided by
13
- * the account instead: it needs a password if this very link is what confirmed
14
- * its email address, i.e. a first sign-in. That is what "an invite sent as a
15
- * magic link" looks like, and it deliberately leaves out someone who already
16
- * has an account, which matters most on a Supabase project shared by several
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, user: {
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, anonClient, assertCredentials, sessionClient } from "./client";
2
- function needsPasswordFor(type, user) {
3
- if (type === "invite" || type === "recovery") return true;
4
- const confirmedAt = user?.email_confirmed_at ? Date.parse(user.email_confirmed_at) : NaN;
5
- return Number.isFinite(confirmedAt) && Date.now() - confirmedAt < JUST_CONFIRMED_MS;
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 { data, error } = await client.auth.setSession({ access_token, refresh_token });
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, data.user) };
72
+ return { success: true, needsPassword: needsPasswordFor(hashType) };
86
73
  }
87
74
  if (code) {
88
- const { data, error } = await client.auth.exchangeCodeForSession(code);
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, data.user) };
77
+ return { success: true, needsPassword: needsPasswordFor(type) };
91
78
  }
92
79
  if (tokenHash && type) {
93
- const { data, error } = await client.auth.verifyOtp({
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, data.user) };
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
- let isNewUser = true;
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
- isNewUser = false;
115
- res = await admin.auth.admin.generateLink({ type: "magiclink", email });
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
- if (res.error || !res.data?.properties?.hashed_token) {
118
- return { success: false, error: res.error?.message ?? "No sign-in link was returned." };
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
- const { hashed_token, verification_type } = res.data.properties;
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 { data, error } = await client.auth.verifyOtp({ type, token_hash: tokenHash });
20
+ const { error } = await client.auth.verifyOtp({ type, token_hash: tokenHash });
21
21
  if (!error) {
22
- if (needsPasswordFor(type, data.user)) {
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 { data, error } = await client.auth.verifyOtp({ type, token_hash: tokenHash });\n if (!error) {\n if (needsPasswordFor(type, data.user)) {\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,MAAM,IAAI,MAAM,OAAO,KAAK,UAAU,EAAE,MAAM,YAAY,UAAU,CAAC;AACnF,UAAI,CAAC,OAAO;AACV,YAAI,iBAAiB,MAAM,KAAK,IAAI,GAAG;AACrC,WAAC,MAAM,QAAQ,GAAG,IAAI,WAAW,gBAAgB,QAAQ;AAAA,YACvD,MAAM;AAAA,YACN,QAAQ;AAAA,UACV,CAAC;AAAA,QACH;AACA,iBAAS,IAAI;AAAA,MACf;AACA,cAAQ,MAAM,kDAAkD,MAAM,OAAO;AAAA,IAC/E;AAEA,aAAS,GAAG,QAAQ,GAAG,SAAS,SAAS,GAAG,IAAI,MAAM,GAAG,oBAAoB;AAAA,EAC/E;AACF;","names":[]}
1
+ {"version":3,"sources":["../../../src/adapters/supabase/confirm.ts"],"sourcesContent":["import 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
- return {
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 return {\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"],"mappings":"AAAA,SAAS,mBAAmB,qBAAqB;AAEjD,SAAS,kBAAkB,iBAAiB;AAY5C,SAAS,SAAS,KAA0B;AAC1C,SAAO;AAAA,IACL,QAAQ,IAAI;AAAA,IACZ,OAAO,IAAI;AAAA,IACX,MAAM,IAAI;AAAA,IACV,QAAQ,IAAI;AAAA,IACZ,GAAI,IAAI,aAAa,EAAE,WAAW,IAAI,WAAW,IAAI,CAAC;AAAA,EACxD;AACF;AAEA,MAAM,UAAU;AAET,SAAS,gBACd,OACqB;AACrB,oBAAkB,KAAK;AAEvB,SAAO;AAAA,IACL,MAAM,UAAU,QAAQ,QAAQ;AAG9B,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,UAAI,QAAQ,OACT,OAAO,SAAS,EAChB,KAAK,gBAAgB,EACrB,OAAO,OAAO,EACd,GAAG,WAAW,MAAM;AAEvB,UAAI,OAAO,OAAQ,SAAQ,MAAM,GAAG,WAAW,OAAO,MAAM;AAAA,eACnD,OAAO;AACd,gBAAQ,MAAM,GAAG,SAAS,OAAO,MAAM,YAAY,EAAE,KAAK,CAAC;AAAA,UACxD,QAAO;AAEZ,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,MAAM,YAAY;AAChD,UAAI,OAAO;AACT,gBAAQ,MAAM,8CAA8C,MAAM,OAAO;AACzE,eAAO;AAAA,MACT;AACA,aAAO,OAAO,SAAS,IAAiB,IAAI;AAAA,IAC9C;AAAA,IAEA,MAAM,YAAY,QAAQ;AACxB,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,OAC3B,OAAO,SAAS,EAChB,KAAK,gBAAgB,EACrB,OAAO,OAAO,EACd,GAAG,WAAW,MAAM,EACpB,MAAM,OAAO;AAEhB,UAAI,OAAO;AACT,gBAAQ,MAAM,6CAA6C,MAAM,OAAO;AACxE,eAAO,CAAC;AAAA,MACV;AACA,cAAS,QAAQ,CAAC,GAAmB,IAAI,QAAQ;AAAA,IACnD;AAAA,IAEA,MAAM,aAAa,QAAQ,QAAQ;AACjC,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,IAAI,MAAM,OACrB,OAAO,SAAS,EAChB,KAAK,gBAAgB,EACrB;AAAA,QACC;AAAA,UACE,SAAS;AAAA,UACT,OAAO,OAAO,MAAM,YAAY,EAAE,KAAK;AAAA,UACvC,MAAM,OAAO;AAAA,UACb,SAAS,OAAO,UAAU;AAAA,UAC1B,QAAQ,OAAO;AAAA,UACf,aAAY,oBAAI,KAAK,GAAE,YAAY;AAAA,QACrC;AAAA,QACA,EAAE,YAAY,gBAAgB;AAAA,MAChC;AACF,aAAO,QACH,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ,IACvC,EAAE,SAAS,KAAK;AAAA,IACtB;AAAA,IAEA,MAAM,aAAa,QAAQ,OAAO;AAChC,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,IAAI,MAAM,OACrB,OAAO,SAAS,EAChB,KAAK,gBAAgB,EACrB,OAAO,EACP,GAAG,WAAW,MAAM,EACpB,GAAG,SAAS,MAAM,YAAY,EAAE,KAAK,CAAC;AACzC,aAAO,QACH,EAAE,SAAS,OAAO,OAAO,MAAM,QAAQ,IACvC,EAAE,SAAS,KAAK;AAAA,IACtB;AAAA,IAEA,MAAM,YAAY,QAAQ;AACxB,YAAM,SAAS,MAAM,cAAc,KAAK;AAGxC,YAAM,EAAE,MAAM,IAAI,MAAM,OAAO,OAAO,SAAS,EAAE,IAAI,mBAAmB;AAAA,QACtE,aAAa;AAAA,MACf,CAAC;AACD,UAAI,OAAO;AACT,gBAAQ,MAAM,sCAAsC,MAAM,OAAO;AAAA,MACnE;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
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":[]}