@guard-rail/core 0.7.1 → 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 (51) hide show
  1. package/README.md +86 -41
  2. package/dist/adapters/supabase/auth.d.ts +13 -1
  3. package/dist/adapters/supabase/auth.js +42 -25
  4. package/dist/adapters/supabase/auth.js.map +1 -1
  5. package/dist/adapters/supabase/client.d.ts +16 -1
  6. package/dist/adapters/supabase/client.js +9 -0
  7. package/dist/adapters/supabase/client.js.map +1 -1
  8. package/dist/adapters/supabase/confirm.d.ts +5 -0
  9. package/dist/adapters/supabase/confirm.js +12 -1
  10. package/dist/adapters/supabase/confirm.js.map +1 -1
  11. package/dist/adapters/supabase/members.js +13 -2
  12. package/dist/adapters/supabase/members.js.map +1 -1
  13. package/dist/core/adapter-types.d.ts +80 -12
  14. package/dist/core/config.d.ts +2 -1
  15. package/dist/core/config.js +4 -0
  16. package/dist/core/config.js.map +1 -1
  17. package/dist/core/defs.d.ts +11 -1
  18. package/dist/core/defs.js +11 -1
  19. package/dist/core/defs.js.map +1 -1
  20. package/dist/core/types.d.ts +6 -0
  21. package/dist/email/resend.d.ts +80 -0
  22. package/dist/email/resend.js +118 -0
  23. package/dist/email/resend.js.map +1 -0
  24. package/dist/index.client.d.ts +6 -3
  25. package/dist/index.client.js +14 -0
  26. package/dist/index.client.js.map +1 -1
  27. package/dist/index.d.ts +3 -2
  28. package/dist/index.js +7 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/react/Admin.js +105 -107
  31. package/dist/react/Admin.js.map +1 -1
  32. package/dist/react/AuthDrawer.d.ts +23 -0
  33. package/dist/react/AuthDrawer.js +165 -0
  34. package/dist/react/AuthDrawer.js.map +1 -0
  35. package/dist/react/AuthRedirectSync.js +1 -1
  36. package/dist/react/AuthRedirectSync.js.map +1 -1
  37. package/dist/react/Icons.d.ts +2 -1
  38. package/dist/react/Icons.js +2 -0
  39. package/dist/react/Icons.js.map +1 -1
  40. package/dist/react/Team.d.ts +7 -1
  41. package/dist/react/Team.js +23 -3
  42. package/dist/react/Team.js.map +1 -1
  43. package/dist/server/access.d.ts +3 -1
  44. package/dist/server/access.js +5 -3
  45. package/dist/server/access.js.map +1 -1
  46. package/dist/server/actions.d.ts +25 -4
  47. package/dist/server/actions.js +118 -11
  48. package/dist/server/actions.js.map +1 -1
  49. package/dist/server/wrapper.js +2 -1
  50. package/dist/server/wrapper.js.map +1 -1
  51. package/package.json +8 -10
package/README.md CHANGED
@@ -206,52 +206,94 @@ 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
- },
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.
215
+
216
+ Set it up once, and everything that sends email works:
217
+
218
+ ```ts
219
+ // lib/guardrail.ts
220
+ import { resendEmails } from "@guard-rail/core";
221
+ import { supabaseAdapters } from "@guard-rail/core/adapters/supabase";
222
+
223
+ const supabase = supabaseAdapters({
224
+ url: process.env.SUPABASE_URL!,
225
+ anonKey: process.env.SUPABASE_ANON_KEY!,
226
+ serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY, // server-only
227
+ });
228
+
229
+ withGuardrail(RootLayout, {
230
+ ...supabase,
231
+ siteId: "my-site",
232
+ ...resendEmails({
233
+ from: "My Site <hello@mysite.com>", // a domain verified in Resend
234
+ siteName: "My Site",
235
+ // apiKey defaults to process.env.RESEND_API_KEY
236
+ }),
216
237
  });
217
238
  ```
218
239
 
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 has a valid session but no password yet, so they
227
- get a **Set Password** prompt in place of the usual toolbar — skippable,
228
- since a magic link works fine on its own indefinitely if they'd rather not.
229
- The same prompt appears after a password-recovery link.
230
-
231
- Two things still worth knowing:
232
-
233
- - **Auth → URL Configuration**, in the Supabase dashboard: Site URL and
234
- Redirect URLs must actually match wherever your app runs, including in
235
- local dev — a mismatch here makes Supabase silently redirect to whatever
236
- Site URL *is* configured instead of erroring, which reads exactly like the
237
- link "not working" for no visible reason.
238
- - **Auth → SMTP**: Supabase's built-in mailer allows only a few emails an
239
- hour. Configure custom SMTP (Resend, Postmark, SES) before relying on
240
- invites. Submitting the same address again re-sends a pending invite.
241
-
242
- Prefer a route that verifies server-side rather than relying on client JS to
243
- notice the redirect? `supabaseConfirmRoute` still works exactly as before —
244
- mount it and point a custom email template's link at
245
- `{{ .SiteURL }}/guardrail/confirm?token_hash={{ .TokenHash }}&type=email`:
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
+ **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`:
246
279
 
247
280
  ```ts
248
- // app/guardrail/confirm/route.ts
249
- export const GET = supabaseConfirmRoute({ url, anonKey });
250
- ```
281
+ ...resendEmails({
282
+ from: "My Site <hello@mysite.com>",
283
+ render: ({ link, siteName, isNewUser }) => ({
284
+ subject: `Welcome to ${siteName}`,
285
+ html: `<a href="${link}">${isNewUser ? "Set your password" : "Sign in"}</a>`,
286
+ }),
287
+ renderReset: ({ link, siteName }) => ({
288
+ subject: `Reset your ${siteName} password`,
289
+ html: `<a href="${link}">Choose a new password</a>`,
290
+ }),
291
+ }),
251
292
 
252
- The default `{{ .ConfirmationURL }}` uses a PKCE code that only works in the
253
- browser that requested it — the admin's, not the invitee's — so a custom
254
- template needs the token hash form above either way.
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
+ ```
255
297
 
256
298
  **Bootstrapping.** A site with no members has nobody who can add members, and
257
299
  there is deliberately no in-app way to grant yourself a role — that would mean
@@ -515,8 +557,11 @@ an account with no role on the site — the toolbar's normal exit button ends ed
515
557
  keeps the session, so it doesn't call it). `authenticate` is optional —
516
558
  adapters built on magic links, SSO, or a hosted sign-in page omit it and set
517
559
  `signInUrl`, and Guardrail links there instead of rendering a password form it
518
- cannot fulfil. `inviteUser` is optional too; without it, invites fall back to
519
- 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`.
520
565
 
521
566
  ```ts
522
567
  const clerkAuth: GRAuthAdapter = {
@@ -4,6 +4,18 @@ import '@supabase/supabase-js';
4
4
  import 'react';
5
5
  import '../../core/types.js';
6
6
 
7
+ /**
8
+ * Whether an emailed link should land on the Set Password step.
9
+ *
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.
17
+ */
18
+ declare function needsPasswordFor(type: string | null | undefined): boolean;
7
19
  declare function supabaseAuth(creds: SupabaseCredentials): GRAuthAdapter;
8
20
 
9
- export { supabaseAuth };
21
+ export { needsPasswordFor, supabaseAuth };
@@ -1,8 +1,19 @@
1
- import { anonClient, assertCredentials, sessionClient } from "./client";
2
- const NEEDS_PASSWORD_TYPES = /* @__PURE__ */ new Set(["invite", "recovery", "magiclink"]);
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();
13
+ }
3
14
  function supabaseAuth(creds) {
4
15
  assertCredentials(creds);
5
- return {
16
+ const adapter = {
6
17
  async authenticate(email, password) {
7
18
  const client = await sessionClient(creds);
8
19
  const { data, error } = await client.auth.signInWithPassword({
@@ -27,25 +38,6 @@ function supabaseAuth(creds) {
27
38
  if (error || !data.user) return null;
28
39
  return { userId: data.user.id, email: data.user.email };
29
40
  },
30
- /**
31
- * Sends a sign-in link that creates the account on first use.
32
- *
33
- * Requested through the cookie-free client on purpose. A session client
34
- * would use PKCE and drop the code verifier into the *admin's* browser,
35
- * so the link would fail when the invitee opened it anywhere else. The
36
- * email template must point at the confirm route with {{ .TokenHash }},
37
- * which verifies server-side and needs no verifier.
38
- */
39
- async inviteUser(email, options) {
40
- const { error } = await anonClient(creds).auth.signInWithOtp({
41
- email,
42
- options: {
43
- shouldCreateUser: true,
44
- ...options?.redirectTo ? { emailRedirectTo: options.redirectTo } : {}
45
- }
46
- });
47
- return error ? { success: false, error: error.message } : { success: true };
48
- },
49
41
  async logout() {
50
42
  const client = await sessionClient(creds);
51
43
  await client.auth.signOut();
@@ -77,12 +69,12 @@ function supabaseAuth(creds) {
77
69
  }
78
70
  const { error } = await client.auth.setSession({ access_token, refresh_token });
79
71
  if (error) return { success: false, error: error.message };
80
- return { success: true, needsPassword: NEEDS_PASSWORD_TYPES.has(hashType ?? "") };
72
+ return { success: true, needsPassword: needsPasswordFor(hashType) };
81
73
  }
82
74
  if (code) {
83
75
  const { error } = await client.auth.exchangeCodeForSession(code);
84
76
  if (error) return { success: false, error: error.message };
85
- return { success: true, needsPassword: NEEDS_PASSWORD_TYPES.has(type ?? "") };
77
+ return { success: true, needsPassword: needsPasswordFor(type) };
86
78
  }
87
79
  if (tokenHash && type) {
88
80
  const { error } = await client.auth.verifyOtp({
@@ -90,7 +82,7 @@ function supabaseAuth(creds) {
90
82
  token_hash: tokenHash
91
83
  });
92
84
  if (error) return { success: false, error: error.message };
93
- return { success: true, needsPassword: NEEDS_PASSWORD_TYPES.has(type) };
85
+ return { success: true, needsPassword: needsPasswordFor(type) };
94
86
  }
95
87
  return { success: false, error: "No redirect data to complete." };
96
88
  },
@@ -100,8 +92,33 @@ function supabaseAuth(creds) {
100
92
  return error ? { success: false, error: error.message } : { success: true };
101
93
  }
102
94
  };
95
+ if (creds.serviceRoleKey) {
96
+ adapter.createInviteLink = async (email, { origin }) => {
97
+ const admin = adminClient(creds);
98
+ const res = await admin.auth.admin.generateLink({ type: "invite", email });
99
+ if (res.error) {
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 };
104
+ }
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." };
114
+ }
115
+ return { success: true, link: tokenLink(origin, hashed, "recovery") };
116
+ };
117
+ }
118
+ return adapter;
103
119
  }
104
120
  export {
121
+ needsPasswordFor,
105
122
  supabaseAuth
106
123
  };
107
124
  //# sourceMappingURL=auth.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/supabase/auth.ts"],"sourcesContent":["import type { EmailOtpType } from \"@supabase/supabase-js\";\nimport { 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// \"invite\" covers Supabase's own admin-API invite flow; \"recovery\" is\n// forgot-password. \"magiclink\" belongs here too — deliberately, not an\n// oversight — because inviteUser() below sends one (see its own comment for\n// why) rather than an \"invite\"-typed email, so this is the type Guardrail's\n// actual, real invite flow produces. A returning magic-link user who already\n// has a password sees this redundantly, but it's skippable, and the\n// alternative — never prompting the person this feature exists for — isn't\n// an acceptable trade to avoid that.\nconst NEEDS_PASSWORD_TYPES = new Set([\"invite\", \"recovery\", \"magiclink\"]);\n\nexport function supabaseAuth(creds: SupabaseCredentials): GRAuthAdapter {\n assertCredentials(creds);\n\n return {\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 { error } = await client.auth.setSession({ access_token, refresh_token });\n if (error) return { success: false, error: error.message };\n return { success: true, needsPassword: NEEDS_PASSWORD_TYPES.has(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: NEEDS_PASSWORD_TYPES.has(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: NEEDS_PASSWORD_TYPES.has(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"],"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":[]}
@@ -11,6 +11,14 @@ import { SupabaseClient } from '@supabase/supabase-js';
11
11
  interface SupabaseCredentials {
12
12
  url: string;
13
13
  anonKey: string;
14
+ /**
15
+ * Optional, and only for branded invite emails (see createInviteLink): it
16
+ * lets Guardrail create an account and mint a sign-in link server-side, so
17
+ * the site can send the email itself. Project-wide by Supabase's design —
18
+ * it bypasses RLS and reaches every schema — so read it from a server-only
19
+ * environment variable (never NEXT_PUBLIC_), and it is used for nothing else.
20
+ */
21
+ serviceRoleKey?: string;
14
22
  }
15
23
  declare function assertCredentials(creds: SupabaseCredentials): void;
16
24
  /**
@@ -24,5 +32,12 @@ declare function anonClient(creds: SupabaseCredentials): SupabaseClient;
24
32
  * the signed-in user and RLS can enforce authorization independently.
25
33
  */
26
34
  declare function sessionClient(creds: SupabaseCredentials): Promise<SupabaseClient>;
35
+ /**
36
+ * Service-role client, for the one admin call Guardrail makes with it
37
+ * (generating an invite link). Throws if no key was configured — callers are
38
+ * expected to have checked, since the adapter only exposes that capability when
39
+ * one was.
40
+ */
41
+ declare function adminClient(creds: SupabaseCredentials): SupabaseClient;
27
42
 
28
- export { type SupabaseCredentials, anonClient, assertCredentials, sessionClient };
43
+ export { type SupabaseCredentials, adminClient, anonClient, assertCredentials, sessionClient };
@@ -36,7 +36,16 @@ async function sessionClient(creds) {
36
36
  }
37
37
  });
38
38
  }
39
+ function adminClient(creds) {
40
+ if (!creds.serviceRoleKey) {
41
+ throw new Error("Guardrail (Supabase): serviceRoleKey is not configured.");
42
+ }
43
+ return createClient(creds.url, creds.serviceRoleKey, {
44
+ auth: { persistSession: false, autoRefreshToken: false }
45
+ });
46
+ }
39
47
  export {
48
+ adminClient,
40
49
  anonClient,
41
50
  assertCredentials,
42
51
  sessionClient
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/supabase/client.ts"],"sourcesContent":["import { createServerClient } from \"@supabase/ssr\";\nimport { createClient, type SupabaseClient } from \"@supabase/supabase-js\";\nimport { cookies } from \"next/headers\";\n\n/**\n * Credentials are passed in, never read from process.env here.\n *\n * An adapter that reaches into the environment hides its own requirements —\n * nothing in the type signature says which vars must exist, so a missing or\n * stale one surfaces as a runtime fetch failure. It also makes two sites on\n * two different projects impossible, since the credentials become global.\n */\nexport interface SupabaseCredentials {\n url: string;\n anonKey: string;\n}\n\nexport function assertCredentials(creds: SupabaseCredentials): void {\n if (!creds?.url || !creds?.anonKey) {\n throw new Error(\n \"Guardrail (Supabase): both `url` and `anonKey` are required.\",\n );\n }\n}\n\nconst anonClients = new Map<string, SupabaseClient>();\n\n/**\n * Cookie-free client for reads. A single cookies() call in the render path\n * opts the whole route out of static generation, so public content reads must\n * never touch them.\n */\nexport function anonClient(creds: SupabaseCredentials): SupabaseClient {\n const key = `${creds.url}::${creds.anonKey}`;\n let client = anonClients.get(key);\n if (!client) {\n client = createClient(creds.url, creds.anonKey, {\n auth: { persistSession: false, autoRefreshToken: false },\n });\n anonClients.set(key, client);\n }\n return client;\n}\n\n/**\n * Request-scoped client carrying the editor's auth cookies, so writes run as\n * the signed-in user and RLS can enforce authorization independently.\n */\nexport async function sessionClient(\n creds: SupabaseCredentials,\n): Promise<SupabaseClient> {\n const cookieStore = await cookies();\n\n return createServerClient(creds.url, creds.anonKey, {\n cookies: {\n getAll: () => cookieStore.getAll(),\n setAll: (cookiesToSet) => {\n try {\n cookiesToSet.forEach(({ name, value, options }) =>\n cookieStore.set(name, value, options),\n );\n } catch {\n // Read-only cookies in a Server Component; safe to ignore when a\n // Server Action or middleware refreshes the session.\n }\n },\n },\n });\n}\n"],"mappings":"AAAA,SAAS,0BAA0B;AACnC,SAAS,oBAAyC;AAClD,SAAS,eAAe;AAejB,SAAS,kBAAkB,OAAkC;AAClE,MAAI,CAAC,OAAO,OAAO,CAAC,OAAO,SAAS;AAClC,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;AAEA,MAAM,cAAc,oBAAI,IAA4B;AAO7C,SAAS,WAAW,OAA4C;AACrE,QAAM,MAAM,GAAG,MAAM,GAAG,KAAK,MAAM,OAAO;AAC1C,MAAI,SAAS,YAAY,IAAI,GAAG;AAChC,MAAI,CAAC,QAAQ;AACX,aAAS,aAAa,MAAM,KAAK,MAAM,SAAS;AAAA,MAC9C,MAAM,EAAE,gBAAgB,OAAO,kBAAkB,MAAM;AAAA,IACzD,CAAC;AACD,gBAAY,IAAI,KAAK,MAAM;AAAA,EAC7B;AACA,SAAO;AACT;AAMA,eAAsB,cACpB,OACyB;AACzB,QAAM,cAAc,MAAM,QAAQ;AAElC,SAAO,mBAAmB,MAAM,KAAK,MAAM,SAAS;AAAA,IAClD,SAAS;AAAA,MACP,QAAQ,MAAM,YAAY,OAAO;AAAA,MACjC,QAAQ,CAAC,iBAAiB;AACxB,YAAI;AACF,uBAAa;AAAA,YAAQ,CAAC,EAAE,MAAM,OAAO,QAAQ,MAC3C,YAAY,IAAI,MAAM,OAAO,OAAO;AAAA,UACtC;AAAA,QACF,QAAQ;AAAA,QAGR;AAAA,MACF;AAAA,IACF;AAAA,EACF,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../../../src/adapters/supabase/client.ts"],"sourcesContent":["import { createServerClient } from \"@supabase/ssr\";\nimport { createClient, type SupabaseClient } from \"@supabase/supabase-js\";\nimport { cookies } from \"next/headers\";\n\n/**\n * Credentials are passed in, never read from process.env here.\n *\n * An adapter that reaches into the environment hides its own requirements —\n * nothing in the type signature says which vars must exist, so a missing or\n * stale one surfaces as a runtime fetch failure. It also makes two sites on\n * two different projects impossible, since the credentials become global.\n */\nexport interface SupabaseCredentials {\n url: string;\n anonKey: string;\n /**\n * Optional, and only for branded invite emails (see createInviteLink): it\n * lets Guardrail create an account and mint a sign-in link server-side, so\n * the site can send the email itself. Project-wide by Supabase's design —\n * it bypasses RLS and reaches every schema — so read it from a server-only\n * environment variable (never NEXT_PUBLIC_), and it is used for nothing else.\n */\n serviceRoleKey?: string;\n}\n\nexport function assertCredentials(creds: SupabaseCredentials): void {\n if (!creds?.url || !creds?.anonKey) {\n throw new Error(\n \"Guardrail (Supabase): both `url` and `anonKey` are required.\",\n );\n }\n}\n\nconst anonClients = new Map<string, SupabaseClient>();\n\n/**\n * Cookie-free client for reads. A single cookies() call in the render path\n * opts the whole route out of static generation, so public content reads must\n * never touch them.\n */\nexport function anonClient(creds: SupabaseCredentials): SupabaseClient {\n const key = `${creds.url}::${creds.anonKey}`;\n let client = anonClients.get(key);\n if (!client) {\n client = createClient(creds.url, creds.anonKey, {\n auth: { persistSession: false, autoRefreshToken: false },\n });\n anonClients.set(key, client);\n }\n return client;\n}\n\n/**\n * Request-scoped client carrying the editor's auth cookies, so writes run as\n * the signed-in user and RLS can enforce authorization independently.\n */\nexport async function sessionClient(\n creds: SupabaseCredentials,\n): Promise<SupabaseClient> {\n const cookieStore = await cookies();\n\n return createServerClient(creds.url, creds.anonKey, {\n cookies: {\n getAll: () => cookieStore.getAll(),\n setAll: (cookiesToSet) => {\n try {\n cookiesToSet.forEach(({ name, value, options }) =>\n cookieStore.set(name, value, options),\n );\n } catch {\n // Read-only cookies in a Server Component; safe to ignore when a\n // Server Action or middleware refreshes the session.\n }\n },\n },\n });\n}\n\n/**\n * Service-role client, for the one admin call Guardrail makes with it\n * (generating an invite link). Throws if no key was configured — callers are\n * expected to have checked, since the adapter only exposes that capability when\n * one was.\n */\nexport function adminClient(creds: SupabaseCredentials): SupabaseClient {\n if (!creds.serviceRoleKey) {\n throw new Error(\"Guardrail (Supabase): serviceRoleKey is not configured.\");\n }\n return createClient(creds.url, creds.serviceRoleKey, {\n auth: { persistSession: false, autoRefreshToken: false },\n });\n}\n"],"mappings":"AAAA,SAAS,0BAA0B;AACnC,SAAS,oBAAyC;AAClD,SAAS,eAAe;AAuBjB,SAAS,kBAAkB,OAAkC;AAClE,MAAI,CAAC,OAAO,OAAO,CAAC,OAAO,SAAS;AAClC,UAAM,IAAI;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;AAEA,MAAM,cAAc,oBAAI,IAA4B;AAO7C,SAAS,WAAW,OAA4C;AACrE,QAAM,MAAM,GAAG,MAAM,GAAG,KAAK,MAAM,OAAO;AAC1C,MAAI,SAAS,YAAY,IAAI,GAAG;AAChC,MAAI,CAAC,QAAQ;AACX,aAAS,aAAa,MAAM,KAAK,MAAM,SAAS;AAAA,MAC9C,MAAM,EAAE,gBAAgB,OAAO,kBAAkB,MAAM;AAAA,IACzD,CAAC;AACD,gBAAY,IAAI,KAAK,MAAM;AAAA,EAC7B;AACA,SAAO;AACT;AAMA,eAAsB,cACpB,OACyB;AACzB,QAAM,cAAc,MAAM,QAAQ;AAElC,SAAO,mBAAmB,MAAM,KAAK,MAAM,SAAS;AAAA,IAClD,SAAS;AAAA,MACP,QAAQ,MAAM,YAAY,OAAO;AAAA,MACjC,QAAQ,CAAC,iBAAiB;AACxB,YAAI;AACF,uBAAa;AAAA,YAAQ,CAAC,EAAE,MAAM,OAAO,QAAQ,MAC3C,YAAY,IAAI,MAAM,OAAO,OAAO;AAAA,UACtC;AAAA,QACF,QAAQ;AAAA,QAGR;AAAA,MACF;AAAA,IACF;AAAA,EACF,CAAC;AACH;AAQO,SAAS,YAAY,OAA4C;AACtE,MAAI,CAAC,MAAM,gBAAgB;AACzB,UAAM,IAAI,MAAM,yDAAyD;AAAA,EAC3E;AACA,SAAO,aAAa,MAAM,KAAK,MAAM,gBAAgB;AAAA,IACnD,MAAM,EAAE,gBAAgB,OAAO,kBAAkB,MAAM;AAAA,EACzD,CAAC;AACH;","names":[]}
@@ -11,6 +11,11 @@ import '@supabase/supabase-js';
11
11
  * verifyOtp with a token_hash works in whichever browser opens the link, unlike
12
12
  * a PKCE code exchange. Once the session cookie is set, the next render claims
13
13
  * any pending invitation addressed to that email.
14
+ *
15
+ * An invite, magic link, or recovery link also sets the same "needs a
16
+ * password" flag the in-page redirect handler does, so the person lands on the
17
+ * Set Password prompt rather than silently signed in with no way to log in
18
+ * next time — both ways of completing a link behave the same.
14
19
  */
15
20
  declare function supabaseConfirmRoute(creds: SupabaseCredentials, options?: {
16
21
  next?: string;
@@ -1,5 +1,8 @@
1
+ import { cookies } from "next/headers";
1
2
  import { redirect } from "next/navigation";
2
3
  import { assertCredentials, sessionClient } from "./client";
4
+ import { needsPasswordFor } from "./auth";
5
+ import { GR_COOKIES, GR_NEEDS_PASSWORD_TTL_SECONDS } from "../../core/defs";
3
6
  function safePath(value, fallback) {
4
7
  if (!value || !value.startsWith("/") || value.startsWith("//")) return fallback;
5
8
  return value;
@@ -15,7 +18,15 @@ function supabaseConfirmRoute(creds, options = {}) {
15
18
  if (tokenHash && type) {
16
19
  const client = await sessionClient(creds);
17
20
  const { error } = await client.auth.verifyOtp({ type, token_hash: tokenHash });
18
- if (!error) redirect(next);
21
+ if (!error) {
22
+ if (needsPasswordFor(type)) {
23
+ (await cookies()).set(GR_COOKIES.NEEDS_PASSWORD, "true", {
24
+ path: "/",
25
+ maxAge: GR_NEEDS_PASSWORD_TTL_SECONDS
26
+ });
27
+ }
28
+ redirect(next);
29
+ }
19
30
  console.error("Guardrail (Supabase): link verification failed", error.message);
20
31
  }
21
32
  redirect(`${fallback}${fallback.includes("?") ? "&" : "?"}gr_auth_error=link`);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/supabase/confirm.ts"],"sourcesContent":["import type { EmailOtpType } from \"@supabase/supabase-js\";\nimport { redirect } from \"next/navigation\";\nimport { assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\n\n/**\n * Only same-origin paths. Accepting an arbitrary `next` would make this route\n * an open redirect — a signed-in link on your domain that forwards anywhere.\n */\nfunction safePath(value: string | null, fallback: string): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\")) return fallback;\n 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 */\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) redirect(next);\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,gBAAgB;AACzB,SAAS,mBAAmB,qBAAqB;AAOjD,SAAS,SAAS,OAAsB,UAA0B;AAChE,MAAI,CAAC,SAAS,CAAC,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,EAAG,QAAO;AACvE,SAAO;AACT;AAaO,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,MAAO,UAAS,IAAI;AACzB,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":"AAAA,SAAS,aAAa,mBAAmB,qBAAqB;AAE9D,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,QAAM,UAA+B;AAAA,IACnC,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;AAMA,MAAI,MAAM,gBAAgB;AACxB,YAAQ,iBAAiB,OAAO,QAAQ,UAAU;AAChD,YAAM,EAAE,MAAM,MAAM,IAAI,MAAM,YAAY,KAAK,EAC5C,OAAO,SAAS,EAChB,KAAK,gBAAgB,EACrB,OAAO,OAAO,EACd,GAAG,WAAW,MAAM,EACpB,GAAG,SAAS,MAAM,YAAY,EAAE,KAAK,CAAC,EACtC,YAAY;AACf,UAAI,OAAO;AACT,gBAAQ,MAAM,iDAAiD,MAAM,OAAO;AAC5E,eAAO;AAAA,MACT;AACA,aAAO,SAAS;AAAA,IAClB;AAAA,EACF;AAEA,SAAO;AACT;","names":[]}