@plutocms/supabase 0.11.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/.env.example +14 -0
  2. package/CHANGELOG.md +14 -0
  3. package/FEATURES.md +5 -1
  4. package/README.md +66 -0
  5. package/app/components/access/CopyButton.vue +24 -0
  6. package/app/components/access/DeleteRoleModal.vue +73 -0
  7. package/app/components/access/EditRolesSlideover.vue +134 -0
  8. package/app/components/access/InviteModal.vue +288 -0
  9. package/app/components/access/LinkField.vue +19 -0
  10. package/app/components/access/LinkModal.vue +29 -0
  11. package/app/components/access/RevokeModal.vue +83 -0
  12. package/app/components/access/RoleSlideover.vue +251 -0
  13. package/app/components/mail/AuthEmailsPanel.vue +108 -0
  14. package/app/pages/admin/(auth)/accept-invite.vue +155 -0
  15. package/app/pages/admin/settings/roles.vue +187 -0
  16. package/app/pages/admin/settings/users.vue +372 -0
  17. package/app/plugins/pluto-extension.ts +38 -1
  18. package/app/utils/access-ui.ts +100 -0
  19. package/app/utils/auth-driver.ts +3 -0
  20. package/app/utils/invite-link.ts +70 -0
  21. package/app/utils/supabase-project.ts +54 -0
  22. package/db/migrations/008_users_and_roles_admin.sql +462 -0
  23. package/i18n/locales/en.json +194 -1
  24. package/i18n/locales/pt-BR.json +194 -1
  25. package/package.json +3 -2
  26. package/server/api/access/capabilities.get.ts +17 -0
  27. package/server/api/access/roles/[key].delete.ts +18 -0
  28. package/server/api/access/roles/[key].put.ts +26 -0
  29. package/server/api/access/roles.get.ts +17 -0
  30. package/server/api/access/roles.post.ts +25 -0
  31. package/server/api/access/users/[id]/resend.post.ts +172 -0
  32. package/server/api/access/users/[id]/roles.delete.ts +22 -0
  33. package/server/api/access/users/[id]/roles.put.ts +24 -0
  34. package/server/api/access/users/invite-link.post.ts +50 -0
  35. package/server/api/access/users/invite.post.ts +201 -0
  36. package/server/api/access/users.get.ts +17 -0
  37. package/server/api/permissions/me.get.ts +15 -9
  38. package/server/utils/access-errors.ts +148 -0
  39. package/server/utils/access-invite.ts +66 -0
  40. package/server/utils/access-mail.ts +354 -0
  41. package/server/utils/admin-guard.ts +4 -3
  42. package/server/utils/admin-role-guard.ts +44 -0
  43. package/server/utils/rate-limit.ts +84 -0
  44. package/server/utils/service-client.ts +18 -0
  45. package/server/utils/sign-in-link.ts +129 -0
  46. package/shared/types/access.d.ts +41 -0
  47. package/shared/types/supabase.ts +50 -9
@@ -0,0 +1,66 @@
1
+ import { z } from 'zod'
2
+
3
+ /** The page where an invited user sets a password. */
4
+ export const INVITE_ACCEPT_PATH = '/admin/accept-invite'
5
+
6
+ /** Role and capability keys: lower-case letters, digits and an underscore. */
7
+ export const ROLE_KEY_PATTERN = /^[a-z0-9_]+$/
8
+
9
+ export const inviteBodySchema = z.object({
10
+ email: z.string().trim().toLowerCase().pipe(z.email()),
11
+ roleKeys: z.array(z.string().regex(ROLE_KEY_PATTERN)).min(1).max(20),
12
+ displayName: z.string().trim().max(200).optional(),
13
+ /** The language of the invite email. Checked against the admin languages. */
14
+ locale: z.string().trim().min(1).max(35).optional(),
15
+ })
16
+
17
+ /** The body of the resend route. It is optional. */
18
+ export const resendBodySchema = z
19
+ .object({
20
+ locale: z.string().trim().min(1).max(35).optional(),
21
+ })
22
+ .nullish()
23
+
24
+ export const rolesBodySchema = z.object({
25
+ roleKeys: z.array(z.string().regex(ROLE_KEY_PATTERN)).max(20),
26
+ })
27
+
28
+ export const roleBodySchema = z.object({
29
+ label: z.string().trim().min(1).max(100),
30
+ description: z.string().trim().max(500).optional(),
31
+ capabilities: z
32
+ .array(z.string().regex(/^[a-z0-9_]+:[a-z0-9_]+$/))
33
+ .min(1)
34
+ .max(200),
35
+ })
36
+
37
+ export const createRoleBodySchema = roleBodySchema.extend({
38
+ key: z.string().regex(ROLE_KEY_PATTERN).max(50),
39
+ })
40
+
41
+ /** The link target of the invite email, on the origin of this request. */
42
+ export function inviteRedirectUrl(origin: string): string {
43
+ return new URL(INVITE_ACCEPT_PATH, origin).href
44
+ }
45
+
46
+ /** True when GoTrue says that an account with this email already exists. */
47
+ export function isEmailExistsError(
48
+ error: { code?: string, status?: number } | null | undefined
49
+ ): boolean {
50
+ return error?.code === 'email_exists' || error?.code === 'user_already_exists'
51
+ }
52
+
53
+ /** Reads a user id from a route param. Throws a 400 when it is not a UUID. */
54
+ export function requireUserId(value: unknown): string {
55
+ const parsed = z.uuid().safeParse(value)
56
+
57
+ if (!parsed.success) {
58
+ throw createPlutoError({
59
+ statusCode: 400,
60
+ message: 'The user id is not valid.',
61
+ key: 'supabase.access.errors.invalidUserId',
62
+ })
63
+ }
64
+
65
+ return parsed.data
66
+ }
@@ -0,0 +1,354 @@
1
+ import type { H3Event } from 'h3'
2
+ import type { serverSupabaseClient } from '#supabase/server'
3
+ import type { ServiceClient } from './service-client'
4
+ import { mapAccessError } from './access-errors'
5
+ import { INVITE_ACCEPT_PATH } from './access-invite'
6
+ import { emailInvalidError, isEmailInvalidError } from './sign-in-link'
7
+
8
+ type UserClient = Awaited<ReturnType<typeof serverSupabaseClient<Database>>>
9
+
10
+ /** The link types that the accept-invite page can verify. */
11
+ export type AcceptLinkType = 'invite' | 'magiclink' | 'signup'
12
+
13
+ const ACCEPT_LINK_TYPES: readonly string[] = ['invite', 'magiclink', 'signup']
14
+
15
+ function inviteFailed() {
16
+ return createPlutoError({
17
+ statusCode: 502,
18
+ message: 'The invite email could not be sent.',
19
+ key: 'supabase.access.errors.inviteFailed',
20
+ })
21
+ }
22
+
23
+ /** Finds the id of the account with this email, or null. */
24
+ export async function findUserIdByEmail(client: UserClient, email: string): Promise<string | null> {
25
+ const { data, error } = await client.rpc('find_user_id_by_email', { p_email: email })
26
+
27
+ if (error) {
28
+ throw mapAccessError(error)
29
+ }
30
+
31
+ return data ?? null
32
+ }
33
+
34
+ /** Adds roles to a user. It keeps the roles the user has. */
35
+ export async function addRoles(client: UserClient, target: string, roleKeys: string[]): Promise<void> {
36
+ const { error } = await client.rpc('add_user_roles', { target, role_keys: roleKeys })
37
+
38
+ if (error) {
39
+ throw mapAccessError(error)
40
+ }
41
+ }
42
+
43
+ /**
44
+ * The URL that the user opens to accept an invite. The accept-invite page
45
+ * reads `token_hash` and `type`, then calls `verifyOtp`.
46
+ */
47
+ export function acceptInviteUrl(origin: string, hashedToken: string, type: AcceptLinkType): string {
48
+ const url = new URL(INVITE_ACCEPT_PATH, origin)
49
+
50
+ url.searchParams.set('token_hash', hashedToken)
51
+ url.searchParams.set('type', type)
52
+
53
+ return url.href
54
+ }
55
+
56
+ interface GenerateLinkParams {
57
+ email: string
58
+ type: 'invite' | 'magiclink'
59
+ redirectTo: string
60
+ origin: string
61
+ displayName?: string
62
+ }
63
+
64
+ type GeneratedLink =
65
+ | { kind: 'link', userId: string, link: string }
66
+ | { kind: 'exists' }
67
+
68
+ /**
69
+ * Makes a one-time link with the service role key. GoTrue sends no email and
70
+ * skips the email rate limit. An `invite` link creates the user when the
71
+ * email has no account.
72
+ *
73
+ * GoTrue says which type the link has (`verification_type`). The link uses
74
+ * that type when the accept-invite page knows it, else the asked type.
75
+ */
76
+ export async function generateAcceptLink(
77
+ service: ServiceClient,
78
+ params: GenerateLinkParams
79
+ ): Promise<GeneratedLink> {
80
+ const options = { redirectTo: params.redirectTo }
81
+ const { data, error } = params.type === 'invite'
82
+ ? await service.auth.admin.generateLink({
83
+ type: 'invite',
84
+ email: params.email,
85
+ options: { ...options, data: params.displayName ? { display_name: params.displayName } : {} },
86
+ })
87
+ : await service.auth.admin.generateLink({
88
+ type: 'magiclink',
89
+ email: params.email,
90
+ options,
91
+ })
92
+
93
+ if (error) {
94
+ if (error.code === 'email_exists' || error.code === 'user_already_exists') {
95
+ return { kind: 'exists' }
96
+ }
97
+
98
+ if (isEmailInvalidError(error)) {
99
+ throw emailInvalidError()
100
+ }
101
+
102
+ console.error('invite: generateLink failed:', error.message)
103
+
104
+ throw inviteFailed()
105
+ }
106
+
107
+ const hashedToken = data?.properties?.hashed_token
108
+ const userId = data?.user?.id
109
+
110
+ if (!hashedToken || !userId) {
111
+ console.error('invite: generateLink returned no token')
112
+
113
+ throw inviteFailed()
114
+ }
115
+
116
+ const verification = String(data.properties.verification_type ?? '')
117
+ const type = ACCEPT_LINK_TYPES.includes(verification)
118
+ ? (verification as AcceptLinkType)
119
+ : params.type
120
+
121
+ return { kind: 'link', userId, link: acceptInviteUrl(params.origin, hashedToken, type) }
122
+ }
123
+
124
+ interface ProvisionParams {
125
+ client: UserClient
126
+ service: ServiceClient
127
+ email: string
128
+ roleKeys: string[]
129
+ displayName?: string
130
+ redirectTo: string
131
+ origin: string
132
+ /** Make a link for an email that has an account. Default: true. */
133
+ linkForExisting?: boolean
134
+ }
135
+
136
+ export interface ProvisionResult {
137
+ userId: string
138
+ /** Null when the account existed and `linkForExisting` is false. */
139
+ link: string | null
140
+ /** True when this call made the account. False when it had one. */
141
+ created: boolean
142
+ }
143
+
144
+ /** The error of a route that needs the service role key. */
145
+ export function serviceKeyRequiredError() {
146
+ return createPlutoError({
147
+ statusCode: 503,
148
+ message: 'Set NUXT_SUPABASE_SECRET_KEY on the server to make invite links.',
149
+ key: 'supabase.access.errors.serviceKeyRequired',
150
+ })
151
+ }
152
+
153
+ /**
154
+ * Grants the roles and makes a sign-in link, with no email. It needs the
155
+ * service role key.
156
+ *
157
+ * - An email with an account (confirmed or not): the roles are added, and the
158
+ * link is a `magiclink`. The account is never deleted.
159
+ * - A new email: the `invite` link creates the user, then the roles are set.
160
+ * If the roles fail, the new user is deleted.
161
+ * - The account appears after the lookup (a race): the roles are added.
162
+ */
163
+ export async function provisionAccess(params: ProvisionParams): Promise<ProvisionResult> {
164
+ const { client, service, email, roleKeys } = params
165
+ const link = (type: 'invite' | 'magiclink') =>
166
+ generateAcceptLink(service, { ...params, type })
167
+
168
+ async function forExisting(userId: string): Promise<ProvisionResult> {
169
+ await addRoles(client, userId, roleKeys)
170
+
171
+ if (params.linkForExisting === false) {
172
+ return { userId, link: null, created: false }
173
+ }
174
+
175
+ // A magic link signs in AS that account. A confirmed account has its own
176
+ // password and needs no link, so the admin never gets one: this keeps an
177
+ // admin from taking over a customer's or another admin's account. Only an
178
+ // account that never confirmed (a pending invite, or a sign-up with no
179
+ // confirmation) gets a link, because it cannot sign in without one.
180
+ const { data: found, error: lookupError } = await service.auth.admin.getUserById(userId)
181
+
182
+ if (lookupError || !found.user) {
183
+ console.error('invite: getUserById failed for an existing account')
184
+
185
+ throw inviteFailed()
186
+ }
187
+
188
+ if (found.user.email_confirmed_at) {
189
+ return { userId, link: null, created: false }
190
+ }
191
+
192
+ const made = await link('magiclink')
193
+
194
+ if (made.kind !== 'link') {
195
+ throw inviteFailed()
196
+ }
197
+
198
+ return { userId, link: made.link, created: false }
199
+ }
200
+
201
+ const existingId = await findUserIdByEmail(client, email)
202
+
203
+ if (existingId) {
204
+ return forExisting(existingId)
205
+ }
206
+
207
+ const made = await link('invite')
208
+
209
+ if (made.kind === 'exists') {
210
+ const racedId = await findUserIdByEmail(client, email)
211
+
212
+ if (!racedId) {
213
+ throw inviteFailed()
214
+ }
215
+
216
+ return forExisting(racedId)
217
+ }
218
+
219
+ // The lookup found no account, so this call made it. Only this new account
220
+ // may be deleted when the role write fails.
221
+ const { error: setError } = await client.rpc('set_user_roles', {
222
+ target: made.userId,
223
+ role_keys: roleKeys,
224
+ })
225
+
226
+ if (setError) {
227
+ const { error: undoError } = await service.auth.admin.deleteUser(made.userId)
228
+
229
+ if (undoError) {
230
+ console.error('invite: could not delete the new user:', undoError.message)
231
+ }
232
+
233
+ throw mapAccessError(setError)
234
+ }
235
+
236
+ return { userId: made.userId, link: made.link, created: true }
237
+ }
238
+
239
+ /** True when Pluto mail can send. A failed check counts as not ready. */
240
+ export async function plutoMailReady(): Promise<boolean> {
241
+ try {
242
+ return (await getPlutoMailStatus()).configured === true
243
+ } catch {
244
+ return false
245
+ }
246
+ }
247
+
248
+ /** The site name for the email: the `website_title` setting, else the config name. */
249
+ export async function inviteSiteName(client: UserClient): Promise<string> {
250
+ try {
251
+ const { data } = await client
252
+ .from('settings')
253
+ .select('setting_value')
254
+ .eq('setting_key', 'website_title')
255
+ .maybeSingle()
256
+ const title = data?.setting_value?.trim()
257
+
258
+ if (title) {
259
+ return title
260
+ }
261
+ } catch {
262
+ // The setting is not readable. Use the config name below.
263
+ }
264
+
265
+ return String(useRuntimeConfig().public.websiteName ?? 'Pluto')
266
+ }
267
+
268
+ /** The labels of the roles, in the order of the keys. A key with no label stays as it is. */
269
+ async function roleLabels(client: UserClient, roleKeys: string[]): Promise<string[]> {
270
+ try {
271
+ const { data } = await client.rpc('list_roles')
272
+ const labels = new Map((data ?? []).map((role) => [role.key, role.label]))
273
+
274
+ return roleKeys.map((key) => labels.get(key) || key)
275
+ } catch {
276
+ return roleKeys
277
+ }
278
+ }
279
+
280
+ interface InviteMailParams {
281
+ client: UserClient
282
+ to: string
283
+ link: string
284
+ userId: string
285
+ roleKeys: string[]
286
+ inviterName?: string
287
+ locale?: string
288
+ }
289
+
290
+ /**
291
+ * Sends the invite email with Pluto mail. It returns false when the send
292
+ * fails. The caller keeps the user and the roles, and gives the link to the
293
+ * admin. The log holds the error code only: no recipient, link or key.
294
+ */
295
+ export async function sendInviteMail(params: InviteMailParams): Promise<boolean> {
296
+ try {
297
+ const [siteName, roles] = await Promise.all([
298
+ inviteSiteName(params.client),
299
+ roleLabels(params.client, params.roleKeys),
300
+ ])
301
+ const email = await renderPlutoEmail(
302
+ 'invite',
303
+ { url: params.link, inviterName: params.inviterName, roles },
304
+ { locale: params.locale, siteName }
305
+ )
306
+
307
+ await sendPlutoMail({
308
+ to: params.to,
309
+ ...email,
310
+ idempotencyKey: `invite/${params.userId}/${globalThis.crypto.randomUUID()}`,
311
+ })
312
+
313
+ return true
314
+ } catch (error) {
315
+ const code = (error as { code?: unknown } | null)?.code
316
+
317
+ console.error('invite: the Pluto mail send failed:', typeof code === 'string' ? code : 'unknown')
318
+
319
+ return false
320
+ }
321
+ }
322
+
323
+ /** The name of the admin who invites, for the email. Empty when unknown. */
324
+ export function inviterNameOf(user: { user_metadata?: Record<string, unknown> | null }): string | undefined {
325
+ const meta = user.user_metadata ?? {}
326
+
327
+ for (const value of [meta.display_name, meta.full_name, meta.name]) {
328
+ if (typeof value === 'string' && value.trim() !== '') {
329
+ return value.trim()
330
+ }
331
+ }
332
+
333
+ return undefined
334
+ }
335
+
336
+ /**
337
+ * Checks the optional email language against the admin languages. Throws a
338
+ * 400 for a language the admin does not have.
339
+ */
340
+ export function assertInviteLocale(event: H3Event, locale: string | undefined): void {
341
+ if (locale === undefined) {
342
+ return
343
+ }
344
+
345
+ const adminLocales = useRuntimeConfig(event).public.plutoI18n?.adminLocales ?? []
346
+
347
+ if (!adminLocales.includes(locale)) {
348
+ throw createPlutoError({
349
+ statusCode: 400,
350
+ message: 'This language is not supported.',
351
+ key: 'supabase.errors.localeUnsupported',
352
+ })
353
+ }
354
+ }
@@ -22,15 +22,16 @@ import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
22
22
  * so querying it directly has no migration dependency at all.
23
23
  *
24
24
  * The trade-off: a user granted the `admin` role only through
25
- * `user_roles` (see `permissions-storage/SKILL.md`'s "Granting a role by
25
+ * `user_roles` by hand (see `permissions-storage/SKILL.md`'s "Granting a role by
26
26
  * hand"), with `profiles.is_admin` left `false`, cannot pass this check —
27
27
  * only RLS and `has_capability()`-gated routes recognize a role-only
28
28
  * admin. That is an acceptable, narrow limitation: `user_roles` cannot
29
29
  * exist before `004` has been applied, so a role-only admin can only
30
30
  * exist on a site that has already applied every current migration, at
31
31
  * which point there is nothing left for that admin to need
32
- * `/admin/migrations` for. If a future migration ever needs applying by
33
- * a role-only admin, grant them `profiles.is_admin = true` too.
32
+ * `/admin/migrations` for. Migration 008 also closes the gap: `set_user_roles`
33
+ * and `add_user_roles` keep `profiles.is_admin` in step with the `admin`
34
+ * role. After a hand-made grant, set `profiles.is_admin = true` too.
34
35
  *
35
36
  * `serverSupabaseUser` (from `@nuxtjs/supabase`, backed by
36
37
  * `client.auth.getClaims()`) returns decoded JWT claims, not a Supabase
@@ -0,0 +1,44 @@
1
+ import type { H3Event } from 'h3'
2
+ import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
3
+
4
+ /**
5
+ * Guards a server route so only an admin can call it, through
6
+ * `public.is_admin()`. That function reads `profiles.is_admin` and the
7
+ * `admin` role, so it also accepts an admin who has the role only.
8
+ *
9
+ * Use it for the access API (migration 008). Keep `requireAdmin` for the
10
+ * migrations routes: it needs no migration at all.
11
+ *
12
+ * The rpc runs with the session of the caller, so `auth.uid()` is the caller.
13
+ * Throws a 401 with no user, or a 403 for a user who is not an admin.
14
+ * Returns the claims (the user id is `sub`).
15
+ */
16
+ export async function requireAdminRole(event: H3Event) {
17
+ const user = await serverSupabaseUser(event)
18
+
19
+ if (!user) {
20
+ throw createPlutoError({
21
+ statusCode: 401,
22
+ message: 'You must be logged in.',
23
+ key: 'supabase.errors.notLoggedIn',
24
+ })
25
+ }
26
+
27
+ const client = await serverSupabaseClient<Database>(event)
28
+ const { data, error } = await client.rpc('is_admin')
29
+
30
+ if (error) {
31
+ // Logged, never returned: the 403 must not tell the caller why.
32
+ console.error('requireAdminRole: is_admin() failed:', error.message)
33
+ }
34
+
35
+ if (error || data !== true) {
36
+ throw createPlutoError({
37
+ statusCode: 403,
38
+ message: 'Your account is not an admin.',
39
+ key: 'supabase.errors.notAdmin',
40
+ })
41
+ }
42
+
43
+ return user
44
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A small in-memory rate limiter. It keeps the hit times of each key in a
3
+ * sliding window. The state lives in one server process, so it is a brake
4
+ * for a single instance, not a global limit.
5
+ */
6
+
7
+ export interface RateLimitOptions {
8
+ /** The number of hits allowed in the window. */
9
+ limit: number
10
+ /** The window length, in milliseconds. */
11
+ windowMs: number
12
+ }
13
+
14
+ export interface RateLimitResult {
15
+ ok: boolean
16
+ /** Seconds until the next hit is allowed. 0 when `ok`. */
17
+ retryAfter: number
18
+ }
19
+
20
+ export function createRateLimiter(
21
+ options: RateLimitOptions,
22
+ now: () => number = Date.now
23
+ ) {
24
+ const hits = new Map<string, number[]>()
25
+
26
+ return {
27
+ /** Counts one hit for the key. It does not count a refused hit. */
28
+ hit(key: string): RateLimitResult {
29
+ const time = now()
30
+ const recent = (hits.get(key) ?? []).filter(
31
+ (stamp) => time - stamp < options.windowMs
32
+ )
33
+
34
+ if (recent.length >= options.limit) {
35
+ hits.set(key, recent)
36
+
37
+ return {
38
+ ok: false,
39
+ retryAfter: Math.max(
40
+ 1,
41
+ Math.ceil((recent[0]! + options.windowMs - time) / 1000)
42
+ ),
43
+ }
44
+ }
45
+
46
+ recent.push(time)
47
+ hits.set(key, recent)
48
+
49
+ // Drop the keys that have no recent hit, so the map does not grow.
50
+ if (hits.size > 1000) {
51
+ for (const [other, stamps] of hits) {
52
+ if (stamps.every((stamp) => time - stamp >= options.windowMs)) {
53
+ hits.delete(other)
54
+ }
55
+ }
56
+ }
57
+
58
+ return { ok: true, retryAfter: 0 }
59
+ },
60
+ reset() {
61
+ hits.clear()
62
+ },
63
+ }
64
+ }
65
+
66
+ /** 10 invites per 10 minutes for each caller. */
67
+ const inviteLimiter = createRateLimiter({ limit: 10, windowMs: 10 * 60 * 1000 })
68
+
69
+ /**
70
+ * Throws a 429 when the caller sent too many invites. `sub` is the user id
71
+ * of the caller (the JWT `sub` claim).
72
+ */
73
+ export function enforceInviteRateLimit(sub: string) {
74
+ const result = inviteLimiter.hit(sub)
75
+
76
+ if (!result.ok) {
77
+ throw createPlutoError({
78
+ statusCode: 429,
79
+ message: 'Too many invites. Wait a few minutes and try again.',
80
+ key: 'supabase.access.errors.rateLimited',
81
+ data: { retryAfter: result.retryAfter },
82
+ })
83
+ }
84
+ }
@@ -0,0 +1,18 @@
1
+ import type { H3Event } from 'h3'
2
+ import { serverSupabaseServiceRole } from '#supabase/server'
3
+
4
+ /**
5
+ * Returns the Supabase client with the service role key, or null when the
6
+ * key is not set. The key is optional for invites: without it, the invite
7
+ * routes send a sign-in link instead (see `sign-in-link.ts`).
8
+ */
9
+ export function optionalServiceClient(event: H3Event) {
10
+ try {
11
+ return serverSupabaseServiceRole<Database>(event)
12
+ } catch {
13
+ return null
14
+ }
15
+ }
16
+
17
+ /** The client of `optionalServiceClient`, when the key is set. */
18
+ export type ServiceClient = NonNullable<ReturnType<typeof optionalServiceClient>>