@plutocms/supabase 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.1](https://github.com/plutocms/supabase/compare/v0.7.0...v0.7.1) (2026-09-13)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **permissions:** break the migrations-page bootstrap deadlock ([#55](https://github.com/plutocms/supabase/issues/55)) ([19f4338](https://github.com/plutocms/supabase/commit/19f43388827ba1f4699a2347726e56084c06f8ef))
9
+
10
+ ## [0.7.0](https://github.com/plutocms/supabase/compare/v0.6.0...v0.7.0) (2026-09-12)
11
+
12
+
13
+ ### Features
14
+
15
+ * **permissions:** add role-based capabilities, replacing single-bit admin ([#53](https://github.com/plutocms/supabase/issues/53)) ([72f2eb0](https://github.com/plutocms/supabase/commit/72f2eb051efe15e261d3c7fcef95bdf867e11f5a))
16
+
3
17
  ## [0.6.0](https://github.com/plutocms/supabase/compare/v0.5.0...v0.6.0) (2026-09-12)
4
18
 
5
19
 
package/FEATURES.md CHANGED
@@ -8,3 +8,5 @@ policies shared by every project here.
8
8
 
9
9
  - Layer migrations: apply pending, versioned, per-file database migrations from the admin UI,
10
10
  after initial setup. @.claude/skills/layer-migrations/SKILL.md
11
+ - Permissions storage: role-based capabilities backing server route guards and RLS policies.
12
+ @.claude/skills/permissions-storage/SKILL.md
@@ -8,6 +8,20 @@ const route = useRoute()
8
8
  const { isLoggedIn, logout, allowedUnauthenticatedPaths } = await useAuth()
9
9
  const toast = useToast()
10
10
 
11
+ const { load: loadPermissions, clear: clearPermissions } = usePlutoPermissions()
12
+
13
+ watch(
14
+ isLoggedIn,
15
+ (loggedIn) => {
16
+ if (loggedIn) {
17
+ loadPermissions()
18
+ } else {
19
+ clearPermissions()
20
+ }
21
+ },
22
+ { immediate: true }
23
+ )
24
+
11
25
  const visibility = useDocumentVisibility()
12
26
 
13
27
  watch(visibility, async (current, previous) => {
@@ -36,5 +36,19 @@ export default defineNuxtPlugin(() => {
36
36
  hasSettingsModified.value = Date.now()
37
37
  },
38
38
  },
39
+ // No 'system:migrate' capability: applying migrations stays admin-only,
40
+ // gated by requireAdmin (public.is_admin() directly), never a named
41
+ // capability — see server/utils/admin-guard.ts for why.
42
+ capabilities: [
43
+ { id: 'settings-manage', key: 'settings:manage', label: 'Manage site settings' },
44
+ { id: 'users-read', key: 'users:read', label: 'View all user accounts' },
45
+ ],
46
+ permissionsDriver: {
47
+ id: 'supabase',
48
+ load: async () => {
49
+ const response = await $fetch<{ capabilities: string[] }>('/api/permissions/me')
50
+ return response.capabilities
51
+ },
52
+ },
39
53
  })
40
54
  })
@@ -0,0 +1,298 @@
1
+ -- Roles and capabilities
2
+ --
3
+ -- Adds a role-based capability system. A role (public.roles) holds a set
4
+ -- of capabilities (public.role_capabilities); a user (public.user_roles)
5
+ -- holds a set of roles. public.has_capability(cap) answers "can the
6
+ -- calling user do this?" for any layer's server routes and RLS policies.
7
+ --
8
+ -- Standalone and self-sufficient: it does not depend on running as a
9
+ -- diff against another file, only on 001_baseline.sql's and
10
+ -- 002_admin_hardening.sql's objects (public.profiles, public.settings,
11
+ -- public.healthcheck, public.is_admin(), public.handle_new_user())
12
+ -- already existing.
13
+
14
+ -- Free text, never an enum. An enum would stop a layer, or an operator,
15
+ -- from adding a role or a capability — the exact problem
16
+ -- 003_settings_generic_keys.sql already fixed for settings.
17
+ create table if not exists public.roles (
18
+ key text primary key,
19
+ label text not null,
20
+ description text,
21
+ is_builtin boolean not null default false,
22
+ created_at timestamptz not null default now(),
23
+ constraint roles_key_format check (key ~ '^[a-z0-9_]+$')
24
+ );
25
+
26
+ create table if not exists public.role_capabilities (
27
+ role_key text not null references public.roles (key) on delete cascade,
28
+ capability text not null,
29
+ primary key (role_key, capability),
30
+ -- '<namespace>:<action>', or the literal '*' for every capability.
31
+ constraint role_capabilities_format
32
+ check (capability = '*' or capability ~ '^[a-z0-9_]+:[a-z0-9_]+$')
33
+ );
34
+
35
+ create table if not exists public.user_roles (
36
+ user_id uuid not null references auth.users (id) on delete cascade,
37
+ role_key text not null references public.roles (key) on delete cascade,
38
+ granted_at timestamptz not null default now(),
39
+ primary key (user_id, role_key)
40
+ );
41
+
42
+ create index if not exists user_roles_user_id_idx on public.user_roles (user_id);
43
+
44
+ -- Seed the built-in admin role, with the wildcard capability.
45
+ insert into public.roles (key, label, description, is_builtin) values
46
+ ('admin', 'Administrator', 'Full access to every feature.', true)
47
+ on conflict (key) do nothing;
48
+
49
+ insert into public.role_capabilities (role_key, capability) values ('admin', '*')
50
+ on conflict do nothing;
51
+
52
+ -- Every existing admin gets the admin role with no manual step.
53
+ insert into public.user_roles (user_id, role_key)
54
+ select p.id, 'admin' from public.profiles p where p.is_admin = true
55
+ on conflict do nothing;
56
+
57
+ -- Returns true when the calling user holds the given capability, either
58
+ -- directly through a role, or through public.is_admin(). SECURITY
59
+ -- DEFINER for the same reason public.is_admin() is: the lookup must not
60
+ -- be subject to the RLS policies it is being used to decide.
61
+ create or replace function public.has_capability(cap text)
62
+ returns boolean
63
+ language sql security definer set search_path = '' stable
64
+ as $$
65
+ select public.is_admin() or exists (
66
+ select 1
67
+ from public.user_roles ur
68
+ join public.role_capabilities rc on rc.role_key = ur.role_key
69
+ where ur.user_id = auth.uid()
70
+ and (rc.capability = cap or rc.capability = '*')
71
+ );
72
+ $$;
73
+
74
+ -- The flat capability list for the calling user. Backs /api/permissions/me.
75
+ create or replace function public.my_capabilities()
76
+ returns setof text
77
+ language sql security definer set search_path = '' stable
78
+ as $$
79
+ select '*'::text where public.is_admin()
80
+ union
81
+ select rc.capability
82
+ from public.user_roles ur
83
+ join public.role_capabilities rc on rc.role_key = ur.role_key
84
+ where ur.user_id = auth.uid();
85
+ $$;
86
+
87
+ grant execute on function public.has_capability(text) to authenticated;
88
+ grant execute on function public.my_capabilities() to authenticated;
89
+
90
+ -- Reimplements public.is_admin() to also recognize the 'admin' role, so
91
+ -- every existing admin passes every new capability check automatically,
92
+ -- with zero data migration. Kept as create or replace, not a new
93
+ -- function: its name and signature (returns boolean, no arguments,
94
+ -- language sql security definer set search_path = '', stable) never
95
+ -- change. 30 RLS policies across 4 repos call it by name.
96
+ create or replace function public.is_admin()
97
+ returns boolean
98
+ language sql
99
+ security definer
100
+ set search_path = ''
101
+ stable
102
+ as $$
103
+ select coalesce(
104
+ (select p.is_admin from public.profiles p where p.id = auth.uid()),
105
+ false
106
+ ) or exists (
107
+ select 1 from public.user_roles ur
108
+ where ur.user_id = auth.uid() and ur.role_key = 'admin'
109
+ );
110
+ $$;
111
+
112
+ -- Also grant the 'admin' role to a newly-created first-admin account, so
113
+ -- has_capability() sees it the same way is_admin() does. Keeps the exact
114
+ -- existing signature; on_auth_user_created (from 001_baseline.sql)
115
+ -- already points at this function by name, and create or replace is
116
+ -- enough to update its behavior.
117
+ create or replace function public.handle_new_user()
118
+ returns trigger
119
+ language plpgsql
120
+ security definer set search_path = ''
121
+ as $$
122
+ declare
123
+ profile_count int;
124
+ should_be_admin boolean;
125
+ begin
126
+ select count(*) into profile_count from public.profiles;
127
+
128
+ -- Only the very first account is auto-promoted. Never trust
129
+ -- raw_user_meta_data for this: it is fully client-controlled — anyone can
130
+ -- pass an arbitrary `options.data` straight to Supabase's own
131
+ -- /auth/v1/signup endpoint, bypassing this project's own /api/auth/signup
132
+ -- route entirely — so honoring an `is_admin` key there let any new
133
+ -- signup grant itself admin.
134
+ should_be_admin := profile_count = 0;
135
+
136
+ insert into public.profiles (id, email, username, display_name, is_admin)
137
+ values (
138
+ new.id,
139
+ new.email,
140
+ new.raw_user_meta_data ->> 'username',
141
+ new.raw_user_meta_data ->> 'display_name',
142
+ should_be_admin
143
+ );
144
+
145
+ if should_be_admin then
146
+ insert into public.user_roles (user_id, role_key) values (new.id, 'admin')
147
+ on conflict do nothing;
148
+ end if;
149
+
150
+ return new;
151
+ end;
152
+ $$;
153
+
154
+ -- RLS on the three new tables.
155
+ alter table public.roles enable row level security;
156
+ alter table public.role_capabilities enable row level security;
157
+ alter table public.user_roles enable row level security;
158
+
159
+ drop policy if exists "Enable read access for authenticated users on roles" on public.roles;
160
+
161
+ create policy "Enable read access for authenticated users on roles"
162
+ on public.roles
163
+ for select
164
+ to authenticated
165
+ using (true);
166
+
167
+ drop policy if exists "Enable insert for admins on roles" on public.roles;
168
+
169
+ create policy "Enable insert for admins on roles"
170
+ on public.roles
171
+ for insert
172
+ to authenticated
173
+ with check (public.is_admin());
174
+
175
+ drop policy if exists "Enable update for admins on roles" on public.roles;
176
+
177
+ create policy "Enable update for admins on roles"
178
+ on public.roles
179
+ for update
180
+ to authenticated
181
+ using (public.is_admin())
182
+ with check (public.is_admin());
183
+
184
+ drop policy if exists "Enable delete for admins on roles" on public.roles;
185
+
186
+ create policy "Enable delete for admins on roles"
187
+ on public.roles
188
+ for delete
189
+ to authenticated
190
+ using (public.is_admin());
191
+
192
+ drop policy if exists "Enable read access for authenticated users on role_capabilities" on public.role_capabilities;
193
+
194
+ create policy "Enable read access for authenticated users on role_capabilities"
195
+ on public.role_capabilities
196
+ for select
197
+ to authenticated
198
+ using (true);
199
+
200
+ drop policy if exists "Enable insert for admins on role_capabilities" on public.role_capabilities;
201
+
202
+ create policy "Enable insert for admins on role_capabilities"
203
+ on public.role_capabilities
204
+ for insert
205
+ to authenticated
206
+ with check (public.is_admin());
207
+
208
+ drop policy if exists "Enable update for admins on role_capabilities" on public.role_capabilities;
209
+
210
+ create policy "Enable update for admins on role_capabilities"
211
+ on public.role_capabilities
212
+ for update
213
+ to authenticated
214
+ using (public.is_admin())
215
+ with check (public.is_admin());
216
+
217
+ drop policy if exists "Enable delete for admins on role_capabilities" on public.role_capabilities;
218
+
219
+ create policy "Enable delete for admins on role_capabilities"
220
+ on public.role_capabilities
221
+ for delete
222
+ to authenticated
223
+ using (public.is_admin());
224
+
225
+ -- A user may always read their own role grants; only an admin reads
226
+ -- everyone's. Every write is admin-only, with no exception: with check
227
+ -- (true) here would let any authenticated user grant themselves the
228
+ -- 'admin' role, a privilege-escalation hole identical in shape to the
229
+ -- one closed in 002_admin_hardening.sql.
230
+ drop policy if exists "Enable read access for own or admin on user_roles" on public.user_roles;
231
+
232
+ create policy "Enable read access for own or admin on user_roles"
233
+ on public.user_roles
234
+ for select
235
+ to authenticated
236
+ using (user_id = auth.uid() or public.is_admin());
237
+
238
+ drop policy if exists "Enable insert for admins on user_roles" on public.user_roles;
239
+
240
+ create policy "Enable insert for admins on user_roles"
241
+ on public.user_roles
242
+ for insert
243
+ to authenticated
244
+ with check (public.is_admin());
245
+
246
+ drop policy if exists "Enable update for admins on user_roles" on public.user_roles;
247
+
248
+ create policy "Enable update for admins on user_roles"
249
+ on public.user_roles
250
+ for update
251
+ to authenticated
252
+ using (public.is_admin())
253
+ with check (public.is_admin());
254
+
255
+ drop policy if exists "Enable delete for admins on user_roles" on public.user_roles;
256
+
257
+ create policy "Enable delete for admins on user_roles"
258
+ on public.user_roles
259
+ for delete
260
+ to authenticated
261
+ using (public.is_admin());
262
+
263
+ -- Rewrite this repo's own three policies to named capabilities. Since
264
+ -- has_capability() folds in is_admin() through the 'admin' role check,
265
+ -- every existing admin is unaffected by this change.
266
+ drop policy if exists "Enable read access for authenticated users" on public.profiles;
267
+
268
+ create policy "Enable read access for authenticated users"
269
+ on public.profiles
270
+ for select
271
+ to authenticated, dashboard_user
272
+ using (auth.uid() = id or public.has_capability('users:read'));
273
+
274
+ drop policy if exists "Enable insert for authenticated users only" on public.settings;
275
+
276
+ create policy "Enable insert for authenticated users only"
277
+ on public.settings
278
+ for insert
279
+ to authenticated, dashboard_user
280
+ with check (public.has_capability('settings:manage'));
281
+
282
+ drop policy if exists "Enable update for authenticated users on settings" on public.settings;
283
+
284
+ create policy "Enable update for authenticated users on settings"
285
+ on public.settings
286
+ for update
287
+ to authenticated, dashboard_user
288
+ using (public.has_capability('settings:manage'))
289
+ with check (public.has_capability('settings:manage'));
290
+
291
+ drop policy if exists "Enable update for authenticated users on healthcheck" on public.healthcheck;
292
+
293
+ create policy "Enable update for authenticated users on healthcheck"
294
+ on public.healthcheck
295
+ for update
296
+ to authenticated, dashboard_user
297
+ using (public.has_capability('settings:manage'))
298
+ with check (public.has_capability('settings:manage'));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/supabase",
3
3
  "type": "module",
4
- "version": "0.6.0",
4
+ "version": "0.7.1",
5
5
  "trustedDependencies": [
6
6
  "@parcel/watcher",
7
7
  "@plutocms/pluto",
@@ -39,7 +39,7 @@
39
39
  "@nuxt/eslint": "^1.16.0",
40
40
  "@nuxt/ui": "^4.9.0",
41
41
  "@nuxtjs/supabase": "^2.0.9",
42
- "@plutocms/pluto": "^0.4.0",
42
+ "@plutocms/pluto": "^0.5.0",
43
43
  "@plutocms/utils": "^0.2.1",
44
44
  "postgres": "^3.4.9",
45
45
  "supabase": "^2.109.1"
@@ -11,6 +11,9 @@ interface Payload {
11
11
  }
12
12
 
13
13
  export default defineEventHandler(async (event) => {
14
+ // requireAdmin specifically, not a named capability — see admin-guard.ts
15
+ // for why: this route must work even before 004_roles_and_capabilities.sql
16
+ // (which defines the capability system) has been applied.
14
17
  await requireAdmin(event)
15
18
 
16
19
  const body = await readBody<Payload | undefined>(event)
@@ -2,6 +2,9 @@ import { requireAdmin } from '../../utils/admin-guard'
2
2
  import { getMigrationStatus } from '../../utils/pending-migrations'
3
3
 
4
4
  export default defineEventHandler(async (event) => {
5
+ // requireAdmin specifically, not a named capability — see admin-guard.ts
6
+ // for why: this route must work even before 004_roles_and_capabilities.sql
7
+ // (which defines the capability system) has been applied.
5
8
  await requireAdmin(event)
6
9
 
7
10
  const status = await getMigrationStatus(event)
@@ -0,0 +1,35 @@
1
+ import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
2
+
3
+ /**
4
+ * The calling user's own capability list. No admin gate — every signed-in
5
+ * user needs this to know what their own UI should show. A signed-out
6
+ * caller gets an empty list, not a 401, so the client composable never
7
+ * has to special-case this endpoint's error path.
8
+ */
9
+ export default defineEventHandler(async (event) => {
10
+ const user = await serverSupabaseUser(event)
11
+
12
+ if (!user) {
13
+ return { capabilities: [] as string[] }
14
+ }
15
+
16
+ const client = await serverSupabaseClient<Database>(event)
17
+ const { data, error } = await client.rpc('my_capabilities')
18
+
19
+ if (error) {
20
+ // public.my_capabilities() is defined by 004_roles_and_capabilities.sql.
21
+ // A site that has upgraded this package but not yet applied that
22
+ // migration has no such function yet — that is an expected, temporary
23
+ // state during an upgrade, not a server error. Degrade to an empty
24
+ // capability list (the same shape a signed-out caller gets above)
25
+ // rather than 500ing: the caller already fails every can() check with
26
+ // an empty list, which is the correct, safe behavior until the pending
27
+ // migration is applied through /admin/migrations (itself gated by
28
+ // requireAdmin, not a capability, precisely so this state is always
29
+ // recoverable — see server/utils/admin-guard.ts).
30
+ console.error('my_capabilities() failed, returning an empty capability list:', error.message)
31
+ return { capabilities: [] as string[] }
32
+ }
33
+
34
+ return { capabilities: data ?? [] }
35
+ })
@@ -1,5 +1,5 @@
1
1
  import { serverSupabaseClient } from '#supabase/server'
2
- import { requireAdmin } from '../../utils/admin-guard'
2
+ import { requireCapability } from '../../utils/capability-guard'
3
3
 
4
4
  // A key is lowercase segments separated by dots, e.g. 'website_title' or
5
5
  // 'blog.posts_per_page'. Mirrors the settings_setting_key_format check
@@ -11,7 +11,7 @@ const SETTING_KEY_PATTERN = /^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/
11
11
  const MAX_SETTING_VALUE_LENGTH = 10_000
12
12
 
13
13
  export default defineEventHandler(async (event) => {
14
- await requireAdmin(event)
14
+ await requireCapability(event, 'settings:manage')
15
15
 
16
16
  const client = await serverSupabaseClient<Database>(event)
17
17
  const body = await readBody<Record<string, unknown>>(event)
@@ -1,8 +1,8 @@
1
1
  import { serverSupabaseClient } from '#supabase/server'
2
- import { requireAdmin } from '../../utils/admin-guard'
2
+ import { requireCapability } from '../../utils/capability-guard'
3
3
 
4
4
  export default defineEventHandler(async (event) => {
5
- await requireAdmin(event)
5
+ await requireCapability(event, 'users:read')
6
6
 
7
7
  const client = await serverSupabaseClient<Database>(event)
8
8
 
@@ -1,8 +1,8 @@
1
1
  import { serverSupabaseClient } from '#supabase/server'
2
- import { requireAdmin } from '../../utils/admin-guard'
2
+ import { requireCapability } from '../../utils/capability-guard'
3
3
 
4
4
  export default defineEventHandler(async (event) => {
5
- await requireAdmin(event)
5
+ await requireCapability(event, 'users:read')
6
6
 
7
7
  const client = await serverSupabaseClient<Database>(event)
8
8
 
@@ -2,23 +2,27 @@ import type { H3Event } from 'h3'
2
2
  import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
3
3
 
4
4
  /**
5
- * Guards a server route so only a logged-in admin can call it.
5
+ * Guards a server route so only an admin can call it.
6
6
  *
7
- * `serverSupabaseUser` (from `@nuxtjs/supabase`, backed by
8
- * `client.auth.getClaims()`) returns decoded JWT claims, not a Supabase
9
- * `User` row. The claims object has no `id` field — the user's id is the
10
- * `sub` claim. Using `user.id` here silently queries `eq('id', undefined)`,
11
- * which matches zero rows and looks exactly like "not an admin" even for a
12
- * real admin. Always read `user.sub`, never `user.id`.
13
- *
14
- * Reads `is_admin` from `public.profiles`, never from `user_metadata`. The
15
- * `handle_new_user` trigger never writes `is_admin` back to
16
- * `auth.users.raw_user_meta_data`, so `user_metadata.is_admin` can be unset
17
- * even for the first admin. `public.profiles.is_admin` is the source of
18
- * truth.
7
+ * Calls `public.is_admin()` directly deliberately NOT through
8
+ * `requireCapability`/`public.has_capability()`. `has_capability()` is
9
+ * defined by `004_roles_and_capabilities.sql`; a site that has upgraded
10
+ * this package but not yet applied that migration has no such function in
11
+ * its database, and every `requireCapability` call would throw. If
12
+ * `requireAdmin` routed through it too, that would 403 the one route
13
+ * (`/api/migrations/*`) an admin needs to actually apply the migration —
14
+ * a deadlock with no escape through the UI. `public.is_admin()` has existed
15
+ * since `002_admin_hardening.sql` and is only ever `create or replace`d,
16
+ * never dropped, so it is always safe to call regardless of which layer
17
+ * migrations have been applied. This is also why the migrations routes
18
+ * call `requireAdmin` specifically, and not a named capability like
19
+ * `system:migrate` — migrations are a bootstrapping concern and must never
20
+ * depend on the capability system migrations themselves create.
19
21
  *
20
22
  * Throws a 401 if there is no logged-in user, or a 403 if the user is not
21
- * an admin. Returns the claims on success.
23
+ * an admin. Returns the user's claims on success. See capability-guard.ts
24
+ * for the same user.sub vs user.id note — this function has the same
25
+ * shape but never reads either field either.
22
26
  */
23
27
  export async function requireAdmin(event: H3Event) {
24
28
  const user = await serverSupabaseUser(event)
@@ -28,18 +32,10 @@ export async function requireAdmin(event: H3Event) {
28
32
  }
29
33
 
30
34
  const client = await serverSupabaseClient<Database>(event)
35
+ const { data, error } = await client.rpc('is_admin')
31
36
 
32
- const { data: profile, error } = await client
33
- .from('profiles')
34
- .select('is_admin')
35
- .eq('id', user.sub)
36
- .single()
37
-
38
- if (error || !profile?.is_admin) {
39
- throw createError({
40
- statusCode: 403,
41
- statusMessage: 'Your account is not an admin.',
42
- })
37
+ if (error || data !== true) {
38
+ throw createError({ statusCode: 403, statusMessage: 'Your account is not an admin.' })
43
39
  }
44
40
 
45
41
  return user
@@ -0,0 +1,52 @@
1
+ import type { H3Event } from 'h3'
2
+ import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
3
+
4
+ /** The wildcard capability. A holder passes every capability check. */
5
+ export const ALL_CAPABILITIES = '*'
6
+
7
+ interface RequireCapabilityOptions {
8
+ /** Overrides the 403 message. */
9
+ message?: string
10
+ }
11
+
12
+ /**
13
+ * Guards a server route on a named capability.
14
+ *
15
+ * `serverSupabaseUser` (from `@nuxtjs/supabase`, backed by
16
+ * `client.auth.getClaims()`) returns decoded JWT claims, not a Supabase
17
+ * `User` row — used here only to distinguish "not logged in" (401) from
18
+ * "logged in but missing the capability" (403). The capability check
19
+ * itself runs in the database, through public.has_capability(), a
20
+ * SECURITY DEFINER function that reads auth.uid() directly from the same
21
+ * session — user.sub/user.id is never passed to it.
22
+ *
23
+ * public.has_capability() folds in public.is_admin(), so every existing
24
+ * admin passes every capability check with no data migration.
25
+ *
26
+ * Throws a 401 if there is no logged-in user, or a 403 if the user is
27
+ * missing the capability. Returns the claims on success.
28
+ */
29
+ export async function requireCapability(
30
+ event: H3Event,
31
+ capability: string,
32
+ options?: RequireCapabilityOptions
33
+ ) {
34
+ const user = await serverSupabaseUser(event)
35
+
36
+ if (!user) {
37
+ throw createError({ statusCode: 401, statusMessage: 'You must be logged in.' })
38
+ }
39
+
40
+ const client = await serverSupabaseClient<Database>(event)
41
+ const { data, error } = await client.rpc('has_capability', { cap: capability })
42
+
43
+ if (error || data !== true) {
44
+ throw createError({
45
+ statusCode: 403,
46
+ statusMessage:
47
+ options?.message ?? `Your account is missing the "${capability}" permission.`,
48
+ })
49
+ }
50
+
51
+ return user
52
+ }
@@ -253,6 +253,53 @@ export type Database = {
253
253
  }
254
254
  Relationships: []
255
255
  }
256
+ role_capabilities: {
257
+ Row: {
258
+ capability: string
259
+ role_key: string
260
+ }
261
+ Insert: {
262
+ capability: string
263
+ role_key: string
264
+ }
265
+ Update: {
266
+ capability?: string
267
+ role_key?: string
268
+ }
269
+ Relationships: [
270
+ {
271
+ foreignKeyName: "role_capabilities_role_key_fkey"
272
+ columns: ["role_key"]
273
+ isOneToOne: false
274
+ referencedRelation: "roles"
275
+ referencedColumns: ["key"]
276
+ },
277
+ ]
278
+ }
279
+ roles: {
280
+ Row: {
281
+ created_at: string
282
+ description: string | null
283
+ is_builtin: boolean
284
+ key: string
285
+ label: string
286
+ }
287
+ Insert: {
288
+ created_at?: string
289
+ description?: string | null
290
+ is_builtin?: boolean
291
+ key: string
292
+ label: string
293
+ }
294
+ Update: {
295
+ created_at?: string
296
+ description?: string | null
297
+ is_builtin?: boolean
298
+ key?: string
299
+ label?: string
300
+ }
301
+ Relationships: []
302
+ }
256
303
  settings: {
257
304
  Row: {
258
305
  id: number
@@ -274,12 +321,49 @@ export type Database = {
274
321
  }
275
322
  Relationships: []
276
323
  }
324
+ user_roles: {
325
+ Row: {
326
+ granted_at: string
327
+ role_key: string
328
+ user_id: string
329
+ }
330
+ Insert: {
331
+ granted_at?: string
332
+ role_key: string
333
+ user_id: string
334
+ }
335
+ Update: {
336
+ granted_at?: string
337
+ role_key?: string
338
+ user_id?: string
339
+ }
340
+ Relationships: [
341
+ {
342
+ foreignKeyName: "user_roles_role_key_fkey"
343
+ columns: ["role_key"]
344
+ isOneToOne: false
345
+ referencedRelation: "roles"
346
+ referencedColumns: ["key"]
347
+ },
348
+ ]
349
+ }
277
350
  }
278
351
  Views: {
279
352
  [_ in never]: never
280
353
  }
281
354
  Functions: {
282
- [_ in never]: never
355
+ has_capability: {
356
+ Args: { cap: string }
357
+ Returns: boolean
358
+ }
359
+ my_capabilities: {
360
+ Args: Record<PropertyKey, never>
361
+ Returns: string[]
362
+ }
363
+ is_admin: {
364
+ Args: Record<PropertyKey, never>
365
+ Returns: boolean
366
+ }
283
367
  }
284
368
  Enums: {
285
369
  thealthcheck: "first_setup"