@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.
- package/README.md +86 -41
- package/dist/adapters/supabase/auth.d.ts +13 -1
- package/dist/adapters/supabase/auth.js +42 -25
- package/dist/adapters/supabase/auth.js.map +1 -1
- package/dist/adapters/supabase/client.d.ts +16 -1
- package/dist/adapters/supabase/client.js +9 -0
- package/dist/adapters/supabase/client.js.map +1 -1
- package/dist/adapters/supabase/confirm.d.ts +5 -0
- package/dist/adapters/supabase/confirm.js +12 -1
- package/dist/adapters/supabase/confirm.js.map +1 -1
- package/dist/adapters/supabase/members.js +13 -2
- package/dist/adapters/supabase/members.js.map +1 -1
- package/dist/core/adapter-types.d.ts +80 -12
- package/dist/core/config.d.ts +2 -1
- package/dist/core/config.js +4 -0
- package/dist/core/config.js.map +1 -1
- package/dist/core/defs.d.ts +11 -1
- package/dist/core/defs.js +11 -1
- package/dist/core/defs.js.map +1 -1
- package/dist/core/types.d.ts +6 -0
- package/dist/email/resend.d.ts +80 -0
- package/dist/email/resend.js +118 -0
- package/dist/email/resend.js.map +1 -0
- package/dist/index.client.d.ts +6 -3
- package/dist/index.client.js +14 -0
- package/dist/index.client.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/react/Admin.js +105 -107
- package/dist/react/Admin.js.map +1 -1
- package/dist/react/AuthDrawer.d.ts +23 -0
- package/dist/react/AuthDrawer.js +165 -0
- package/dist/react/AuthDrawer.js.map +1 -0
- package/dist/react/AuthRedirectSync.js +1 -1
- package/dist/react/AuthRedirectSync.js.map +1 -1
- package/dist/react/Icons.d.ts +2 -1
- package/dist/react/Icons.js +2 -0
- package/dist/react/Icons.js.map +1 -1
- package/dist/react/Team.d.ts +7 -1
- package/dist/react/Team.js +23 -3
- package/dist/react/Team.js.map +1 -1
- package/dist/server/access.d.ts +3 -1
- package/dist/server/access.js +5 -3
- package/dist/server/access.js.map +1 -1
- package/dist/server/actions.d.ts +25 -4
- package/dist/server/actions.js +118 -11
- package/dist/server/actions.js.map +1 -1
- package/dist/server/wrapper.js +2 -1
- package/dist/server/wrapper.js.map +1 -1
- package/package.json +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
|
-
|
|
209
|
+
### Passwords, invites and resets
|
|
210
210
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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. `
|
|
519
|
-
|
|
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 {
|
|
2
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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;
|
|
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)
|
|
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;
|
|
1
|
+
{"version":3,"sources":["../../../src/adapters/supabase/confirm.ts"],"sourcesContent":["import type { EmailOtpType } from \"@supabase/supabase-js\";\nimport { cookies } from \"next/headers\";\nimport { redirect } from \"next/navigation\";\nimport { assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport { needsPasswordFor } from \"./auth\";\nimport { GR_COOKIES, GR_NEEDS_PASSWORD_TTL_SECONDS } from \"../../core/defs\";\n\n/**\n * Only same-origin paths. Accepting an arbitrary `next` would make this route\n * an open redirect — a signed-in link on your domain that forwards anywhere.\n */\nfunction safePath(value: string | null, fallback: string): string {\n if (!value || !value.startsWith(\"/\") || value.startsWith(\"//\")) return fallback;\n return value;\n}\n\n/**\n * Route handler that turns an emailed link into a session.\n *\n * Mount it in the app:\n * // app/guardrail/confirm/route.ts\n * export const GET = supabaseConfirmRoute({ url, anonKey });\n *\n * verifyOtp with a token_hash works in whichever browser opens the link, unlike\n * a PKCE code exchange. Once the session cookie is set, the next render claims\n * any pending invitation addressed to that email.\n *\n * An invite, magic link, or recovery link also sets the same \"needs a\n * password\" flag the in-page redirect handler does, so the person lands on the\n * Set Password prompt rather than silently signed in with no way to log in\n * next time — both ways of completing a link behave the same.\n */\nexport function supabaseConfirmRoute(\n creds: SupabaseCredentials,\n options: { next?: string } = {},\n) {\n assertCredentials(creds);\n const fallback = options.next ?? \"/?edit=true\";\n\n return async function GET(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const tokenHash = url.searchParams.get(\"token_hash\");\n const type = url.searchParams.get(\"type\") as EmailOtpType | null;\n const next = safePath(url.searchParams.get(\"next\"), fallback);\n\n if (tokenHash && type) {\n const client = await sessionClient(creds);\n const { error } = await client.auth.verifyOtp({ type, token_hash: tokenHash });\n if (!error) {\n if (needsPasswordFor(type)) {\n (await cookies()).set(GR_COOKIES.NEEDS_PASSWORD, \"true\", {\n path: \"/\",\n maxAge: GR_NEEDS_PASSWORD_TTL_SECONDS,\n });\n }\n redirect(next);\n }\n console.error(\"Guardrail (Supabase): link verification failed\", error.message);\n }\n\n redirect(`${fallback}${fallback.includes(\"?\") ? \"&\" : \"?\"}gr_auth_error=link`);\n };\n}\n"],"mappings":"AACA,SAAS,eAAe;AACxB,SAAS,gBAAgB;AACzB,SAAS,mBAAmB,qBAAqB;AAEjD,SAAS,wBAAwB;AACjC,SAAS,YAAY,qCAAqC;AAM1D,SAAS,SAAS,OAAsB,UAA0B;AAChE,MAAI,CAAC,SAAS,CAAC,MAAM,WAAW,GAAG,KAAK,MAAM,WAAW,IAAI,EAAG,QAAO;AACvE,SAAO;AACT;AAkBO,SAAS,qBACd,OACA,UAA6B,CAAC,GAC9B;AACA,oBAAkB,KAAK;AACvB,QAAM,WAAW,QAAQ,QAAQ;AAEjC,SAAO,eAAe,IAAI,SAAqC;AAC7D,UAAM,MAAM,IAAI,IAAI,QAAQ,GAAG;AAC/B,UAAM,YAAY,IAAI,aAAa,IAAI,YAAY;AACnD,UAAM,OAAO,IAAI,aAAa,IAAI,MAAM;AACxC,UAAM,OAAO,SAAS,IAAI,aAAa,IAAI,MAAM,GAAG,QAAQ;AAE5D,QAAI,aAAa,MAAM;AACrB,YAAM,SAAS,MAAM,cAAc,KAAK;AACxC,YAAM,EAAE,MAAM,IAAI,MAAM,OAAO,KAAK,UAAU,EAAE,MAAM,YAAY,UAAU,CAAC;AAC7E,UAAI,CAAC,OAAO;AACV,YAAI,iBAAiB,IAAI,GAAG;AAC1B,WAAC,MAAM,QAAQ,GAAG,IAAI,WAAW,gBAAgB,QAAQ;AAAA,YACvD,MAAM;AAAA,YACN,QAAQ;AAAA,UACV,CAAC;AAAA,QACH;AACA,iBAAS,IAAI;AAAA,MACf;AACA,cAAQ,MAAM,kDAAkD,MAAM,OAAO;AAAA,IAC/E;AAEA,aAAS,GAAG,QAAQ,GAAG,SAAS,SAAS,GAAG,IAAI,MAAM,GAAG,oBAAoB;AAAA,EAC/E;AACF;","names":[]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assertCredentials, sessionClient } from "./client";
|
|
1
|
+
import { adminClient, assertCredentials, sessionClient } from "./client";
|
|
2
2
|
import { GR_MEMBERS_TABLE, GR_SCHEMA } from "../../core/defs";
|
|
3
3
|
function toMember(row) {
|
|
4
4
|
return {
|
|
@@ -12,7 +12,7 @@ function toMember(row) {
|
|
|
12
12
|
const COLUMNS = "user_id, email, role, status, invited_at";
|
|
13
13
|
function supabaseMembers(creds) {
|
|
14
14
|
assertCredentials(creds);
|
|
15
|
-
|
|
15
|
+
const adapter = {
|
|
16
16
|
async getMember(siteId, lookup) {
|
|
17
17
|
const client = await sessionClient(creds);
|
|
18
18
|
let query = client.schema(GR_SCHEMA).from(GR_MEMBERS_TABLE).select(COLUMNS).eq("site_id", siteId);
|
|
@@ -66,6 +66,17 @@ function supabaseMembers(creds) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
};
|
|
69
|
+
if (creds.serviceRoleKey) {
|
|
70
|
+
adapter.emailHasAccess = async (siteId, email) => {
|
|
71
|
+
const { data, error } = await adminClient(creds).schema(GR_SCHEMA).from(GR_MEMBERS_TABLE).select("email").eq("site_id", siteId).eq("email", email.toLowerCase().trim()).maybeSingle();
|
|
72
|
+
if (error) {
|
|
73
|
+
console.error("Guardrail (Supabase): membership check failed", error.message);
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return data !== null;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return adapter;
|
|
69
80
|
}
|
|
70
81
|
export {
|
|
71
82
|
supabaseMembers
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/adapters/supabase/members.ts"],"sourcesContent":["import { assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport { GR_MEMBERS_TABLE, GR_SCHEMA } from \"../../core/defs\";\nimport type { GRMembershipAdapter } from \"../../core/adapter-types\";\nimport type { GRMember, GRRole } from \"../../core/types\";\n\ninterface MemberRow {\n user_id: string | null;\n email: string;\n role: GRRole;\n status: \"active\" | \"pending\";\n invited_at: string | null;\n}\n\nfunction toMember(row: MemberRow): GRMember {\n return {\n userId: row.user_id,\n email: row.email,\n role: row.role,\n status: row.status,\n ...(row.invited_at ? { invitedAt: row.invited_at } : {}),\n };\n}\n\nconst COLUMNS = \"user_id, email, role, status, invited_at\";\n\nexport function supabaseMembers(\n creds: SupabaseCredentials,\n): GRMembershipAdapter {\n assertCredentials(creds);\n\n
|
|
1
|
+
{"version":3,"sources":["../../../src/adapters/supabase/members.ts"],"sourcesContent":["import { adminClient, assertCredentials, sessionClient } from \"./client\";\nimport type { SupabaseCredentials } from \"./client\";\nimport { GR_MEMBERS_TABLE, GR_SCHEMA } from \"../../core/defs\";\nimport type { GRMembershipAdapter } from \"../../core/adapter-types\";\nimport type { GRMember, GRRole } from \"../../core/types\";\n\ninterface MemberRow {\n user_id: string | null;\n email: string;\n role: GRRole;\n status: \"active\" | \"pending\";\n invited_at: string | null;\n}\n\nfunction toMember(row: MemberRow): GRMember {\n return {\n userId: row.user_id,\n email: row.email,\n role: row.role,\n status: row.status,\n ...(row.invited_at ? { invitedAt: row.invited_at } : {}),\n };\n}\n\nconst COLUMNS = \"user_id, email, role, status, invited_at\";\n\nexport function supabaseMembers(\n creds: SupabaseCredentials,\n): GRMembershipAdapter {\n assertCredentials(creds);\n\n const adapter: GRMembershipAdapter = {\n async getMember(siteId, lookup) {\n // Session-bound by necessity: RLS evaluates auth.uid(), so the anon\n // client would see nothing and every role would resolve to null.\n const client = await sessionClient(creds);\n let query = client\n .schema(GR_SCHEMA)\n .from(GR_MEMBERS_TABLE)\n .select(COLUMNS)\n .eq(\"site_id\", siteId);\n\n if (lookup.userId) query = query.eq(\"user_id\", lookup.userId);\n else if (lookup.email)\n query = query.eq(\"email\", lookup.email.toLowerCase().trim());\n else return null;\n\n const { data, error } = await query.maybeSingle();\n if (error) {\n console.error(\"Guardrail (Supabase): member lookup failed\", error.message);\n return null;\n }\n return data ? toMember(data as MemberRow) : null;\n },\n\n async listMembers(siteId) {\n const client = await sessionClient(creds);\n const { data, error } = await client\n .schema(GR_SCHEMA)\n .from(GR_MEMBERS_TABLE)\n .select(COLUMNS)\n .eq(\"site_id\", siteId)\n .order(\"email\");\n\n if (error) {\n console.error(\"Guardrail (Supabase): list members failed\", error.message);\n return [];\n }\n return ((data ?? []) as MemberRow[]).map(toMember);\n },\n\n async upsertMember(siteId, member) {\n const client = await sessionClient(creds);\n const { error } = await client\n .schema(GR_SCHEMA)\n .from(GR_MEMBERS_TABLE)\n .upsert(\n {\n site_id: siteId,\n email: member.email.toLowerCase().trim(),\n role: member.role,\n user_id: member.userId ?? null,\n status: member.status,\n invited_at: new Date().toISOString(),\n },\n { onConflict: \"site_id,email\" },\n );\n return error\n ? { success: false, error: error.message }\n : { success: true };\n },\n\n async removeMember(siteId, email) {\n const client = await sessionClient(creds);\n const { error } = await client\n .schema(GR_SCHEMA)\n .from(GR_MEMBERS_TABLE)\n .delete()\n .eq(\"site_id\", siteId)\n .eq(\"email\", email.toLowerCase().trim());\n return error\n ? { success: false, error: error.message }\n : { success: true };\n },\n\n async claimInvite(siteId) {\n const client = await sessionClient(creds);\n // SECURITY DEFINER function: sets user_id/status on a pending row\n // matching the caller's own email, and cannot alter the invited role.\n const { error } = await client.schema(GR_SCHEMA).rpc(\"gr_claim_invite\", {\n target_site: siteId,\n });\n if (error) {\n console.error(\"Guardrail (Supabase): claim failed\", error.message);\n }\n },\n };\n\n // Only with a service-role key: an unauthenticated \"forgot password\" request\n // has no session for RLS to evaluate, so telling members from strangers takes\n // privileged access to the membership table. Without the key the adapter\n // simply doesn't offer it, and Guardrail falls back to Supabase's own behavior.\n if (creds.serviceRoleKey) {\n adapter.emailHasAccess = async (siteId, email) => {\n const { data, error } = await adminClient(creds)\n .schema(GR_SCHEMA)\n .from(GR_MEMBERS_TABLE)\n .select(\"email\")\n .eq(\"site_id\", siteId)\n .eq(\"email\", email.toLowerCase().trim())\n .maybeSingle();\n if (error) {\n console.error(\"Guardrail (Supabase): membership check failed\", error.message);\n return false;\n }\n return data !== null;\n };\n }\n\n return adapter;\n}\n"],"mappings":"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":[]}
|