@plutocms/supabase 0.5.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.0](https://github.com/plutocms/supabase/compare/v0.6.0...v0.7.0) (2026-09-12)
4
+
5
+
6
+ ### Features
7
+
8
+ * **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))
9
+
10
+ ## [0.6.0](https://github.com/plutocms/supabase/compare/v0.5.0...v0.6.0) (2026-09-12)
11
+
12
+
13
+ ### Features
14
+
15
+ * **settings:** generic, namespaced setting keys ([#51](https://github.com/plutocms/supabase/issues/51)) ([2410552](https://github.com/plutocms/supabase/commit/2410552ce6c880ecec1fac6f0b84852ef8612bea))
16
+
3
17
  ## [0.5.0](https://github.com/plutocms/supabase/compare/v0.4.1...v0.5.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
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ const { field } = usePlutoSettings()
3
+
4
+ const websiteTitle = field('website_title')
5
+ const websiteDescription = field('website_description')
6
+ const websiteUrl = field('website_url')
7
+ </script>
8
+
9
+ <template>
10
+ <div class="flex flex-col gap-y-6">
11
+ <UFormField label="Website Title" class="w-full lg:w-1/2">
12
+ <UInput v-model="websiteTitle" placeholder="e.g. My Website" />
13
+ </UFormField>
14
+
15
+ <UFormField label="Website Description" class="w-full lg:w-1/2">
16
+ <UInput
17
+ v-model="websiteDescription"
18
+ placeholder="e.g. My Website Description"
19
+ />
20
+ </UFormField>
21
+
22
+ <UFormField label="Website URL" class="w-full lg:w-1/2">
23
+ <UInput
24
+ v-model="websiteUrl"
25
+ type="url"
26
+ placeholder="e.g. https://example.com"
27
+ />
28
+ </UFormField>
29
+ </div>
30
+ </template>
@@ -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) => {
@@ -1,8 +1,52 @@
1
1
  import NavbarAdminProvider from '../components/navbar/NavbarAdminProvider.vue'
2
+ import SettingsGeneralPanel from '../components/settings/SettingsGeneralPanel.vue'
2
3
 
3
4
  export default defineNuxtPlugin(() => {
4
5
  definePlutoExtension({
5
6
  id: 'supabase',
6
7
  navbar: { id: 'admin-navbar', component: NavbarAdminProvider },
8
+ settingsPanels: [
9
+ {
10
+ id: 'general',
11
+ order: 0,
12
+ title: 'General',
13
+ icon: 'lucide:globe',
14
+ component: SettingsGeneralPanel,
15
+ },
16
+ ],
17
+ settingsDriver: {
18
+ id: 'supabase',
19
+ load: async () => {
20
+ const response = await $fetch<{ settings: Record<string, string> }>(
21
+ '/api/settings'
22
+ )
23
+ return response.settings
24
+ },
25
+ save: async (patch) => {
26
+ await $fetch('/api/settings/update', {
27
+ method: 'POST',
28
+ body: patch,
29
+ })
30
+
31
+ // NavbarAdmin.vue watches this to refresh its own /api/settings
32
+ // read (it shows the site's own URL) — the settings page saving
33
+ // through the new registry must still trigger it, exactly like
34
+ // the old page did.
35
+ const hasSettingsModified = useState<number>('has_settings_modified')
36
+ hasSettingsModified.value = Date.now()
37
+ },
38
+ },
39
+ capabilities: [
40
+ { id: 'settings-manage', key: 'settings:manage', label: 'Manage site settings' },
41
+ { id: 'users-read', key: 'users:read', label: 'View all user accounts' },
42
+ { id: 'system-migrate', key: 'system:migrate', label: 'Apply pending layer migrations' },
43
+ ],
44
+ permissionsDriver: {
45
+ id: 'supabase',
46
+ load: async () => {
47
+ const response = await $fetch<{ capabilities: string[] }>('/api/permissions/me')
48
+ return response.capabilities
49
+ },
50
+ },
7
51
  })
8
52
  })
@@ -0,0 +1,37 @@
1
+ -- Generic settings keys
2
+ --
3
+ -- public.settings has been a fixed 3-value enum since 001_baseline.sql —
4
+ -- a layer other than @plutocms/supabase could never add its own setting
5
+ -- without altering a shared type. This adds a free-text, namespaced key
6
+ -- column alongside the enum column, without removing the enum column
7
+ -- yet. Dropping setting_name and the TSettings enum is left for a later
8
+ -- migration, once no shipped code writes it, to keep this one cheap to
9
+ -- reason about and its effects easy to undo.
10
+ --
11
+ -- Key format: lowercase segments separated by dots, e.g. 'website_title'
12
+ -- or 'blog.posts_per_page'. A layer other than core must namespace its
13
+ -- keys with its own name, so two layers can never collide.
14
+
15
+ alter table public.settings add column if not exists setting_key text;
16
+
17
+ update public.settings set setting_key = setting_name::text where setting_key is null;
18
+
19
+ alter table public.settings alter column setting_name drop not null;
20
+
21
+ create unique index if not exists settings_setting_key_key on public.settings (setting_key);
22
+
23
+ do $$
24
+ begin
25
+ if not exists (
26
+ select 1 from pg_constraint
27
+ where conname = 'settings_setting_key_format'
28
+ and conrelid = 'public.settings'::regclass
29
+ ) then
30
+ alter table public.settings
31
+ add constraint settings_setting_key_format
32
+ check (setting_key ~ '^[a-z0-9_]+(?:\.[a-z0-9_]+)*$') not valid;
33
+ end if;
34
+ end
35
+ $$;
36
+
37
+ alter table public.settings validate constraint settings_setting_key_format;
@@ -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/nuxt.config.ts CHANGED
@@ -35,12 +35,6 @@ export default defineNuxtConfig({
35
35
  },
36
36
  },
37
37
 
38
- vite: {
39
- optimizeDeps: {
40
- include: ['yup'],
41
- },
42
- },
43
-
44
38
  eslint: {
45
39
  config: {
46
40
  nuxt: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/supabase",
3
3
  "type": "module",
4
- "version": "0.5.0",
4
+ "version": "0.7.0",
5
5
  "trustedDependencies": [
6
6
  "@parcel/watcher",
7
7
  "@plutocms/pluto",
@@ -39,11 +39,10 @@
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
- "supabase": "^2.109.1",
46
- "yup": "1.7.1"
45
+ "supabase": "^2.109.1"
47
46
  },
48
47
  "devDependencies": {
49
48
  "@antfu/eslint-config": "^6.2.0",
@@ -1,6 +1,6 @@
1
1
  import type { PlutoMigrationFile } from '../../../shared/types/migrations'
2
2
  import type { MigrationFileResult } from '../../utils/migrations'
3
- import { requireAdmin } from '../../utils/admin-guard'
3
+ import { requireCapability } from '../../utils/capability-guard'
4
4
  import { persistDatabaseUrl } from '../../utils/env-file'
5
5
  import { resolveConnectionString, runPendingMigrations } from '../../utils/migrations'
6
6
  import { scrubConnectionString } from '../../utils/scrub-connection-string'
@@ -11,7 +11,7 @@ interface Payload {
11
11
  }
12
12
 
13
13
  export default defineEventHandler(async (event) => {
14
- await requireAdmin(event)
14
+ await requireCapability(event, 'system:migrate')
15
15
 
16
16
  const body = await readBody<Payload | undefined>(event)
17
17
 
@@ -1,8 +1,8 @@
1
- import { requireAdmin } from '../../utils/admin-guard'
1
+ import { requireCapability } from '../../utils/capability-guard'
2
2
  import { getMigrationStatus } from '../../utils/pending-migrations'
3
3
 
4
4
  export default defineEventHandler(async (event) => {
5
- await requireAdmin(event)
5
+ await requireCapability(event, 'system:migrate')
6
6
 
7
7
  const status = await getMigrationStatus(event)
8
8
 
@@ -0,0 +1,24 @@
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
+ throw createError({ statusCode: 500, statusMessage: error.message })
21
+ }
22
+
23
+ return { capabilities: data ?? [] }
24
+ })
@@ -3,24 +3,23 @@ import { serverSupabaseClient } from '#supabase/server'
3
3
  export default defineEventHandler(async (event) => {
4
4
  const client = await serverSupabaseClient<Database>(event)
5
5
 
6
- const { data } = await client.from('settings').select('*')
6
+ const { data } = await client
7
+ .from('settings')
8
+ .select('setting_key, setting_name, setting_value')
7
9
 
8
- type SettingsKey =
9
- Database['public']['Tables']['settings']['Row']['setting_name']
10
-
11
- // @ts-expect-error: Object.fromEntries may not infer the correct type for settings
12
- const settings: Record<SettingsKey, string> = {
13
- ...Object.fromEntries(
14
- data?.map((item) => [item.setting_name, item.setting_value]) || []
15
- ),
16
- }
10
+ const settings: Record<string, string> = {}
17
11
 
18
12
  data?.forEach((item) => {
19
- if (!item.setting_name || !item.setting_value) {
13
+ // setting_key is null only for a row written before
14
+ // db/migrations/003_settings_generic_keys.sql backfilled it — fall
15
+ // back to the enum column so a mid-migration read never drops a row.
16
+ const key = item.setting_key ?? item.setting_name
17
+
18
+ if (!key || !item.setting_value) {
20
19
  return
21
20
  }
22
21
 
23
- settings[item.setting_name] = item.setting_value
22
+ settings[key] = item.setting_value
24
23
  })
25
24
 
26
25
  return { settings }
@@ -1,36 +1,30 @@
1
1
  import { serverSupabaseClient } from '#supabase/server'
2
- import { requireAdmin } from '../../utils/admin-guard'
2
+ import { requireCapability } from '../../utils/capability-guard'
3
3
 
4
- // Mirrors the TSettings enum in db/migrations/001_baseline.sql. The Postgres enum
5
- // already rejects an unknown key at the DB level (as a raw constraint
6
- // error) — this turns that into a clean 400 instead.
7
- const KNOWN_SETTING_KEYS = [
8
- 'website_title',
9
- 'website_url',
10
- 'website_description',
11
- ] as const
4
+ // A key is lowercase segments separated by dots, e.g. 'website_title' or
5
+ // 'blog.posts_per_page'. Mirrors the settings_setting_key_format check
6
+ // constraint in db/migrations/003_settings_generic_keys.sql — this turns
7
+ // a violation into a clean 400 instead of a raw Postgres error. A layer
8
+ // other than core must namespace its own keys so two layers can never
9
+ // collide.
10
+ const SETTING_KEY_PATTERN = /^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/
11
+ const MAX_SETTING_VALUE_LENGTH = 10_000
12
12
 
13
13
  export default defineEventHandler(async (event) => {
14
- await requireAdmin(event)
15
-
16
- type SettingName =
17
- Database['public']['Tables']['settings']['Insert']['setting_name']
18
- type FormBody = Record<SettingName, string>
14
+ await requireCapability(event, 'settings:manage')
19
15
 
20
16
  const client = await serverSupabaseClient<Database>(event)
21
- const body = await readBody<FormBody>(event)
17
+ const body = await readBody<Record<string, unknown>>(event)
22
18
 
23
19
  if (!body || typeof body !== 'object') {
24
20
  throw createError({ statusCode: 400, statusMessage: 'No payload sent.' })
25
21
  }
26
22
 
27
23
  const transformed = Object.entries(body).map(([key, value]) => {
28
- if (
29
- !KNOWN_SETTING_KEYS.includes(key as (typeof KNOWN_SETTING_KEYS)[number])
30
- ) {
24
+ if (!SETTING_KEY_PATTERN.test(key)) {
31
25
  throw createError({
32
26
  statusCode: 400,
33
- statusMessage: `Unknown setting: ${key}`,
27
+ statusMessage: `Invalid setting key: ${key}`,
34
28
  })
35
29
  }
36
30
 
@@ -41,15 +35,22 @@ export default defineEventHandler(async (event) => {
41
35
  })
42
36
  }
43
37
 
38
+ if (value.length > MAX_SETTING_VALUE_LENGTH) {
39
+ throw createError({
40
+ statusCode: 400,
41
+ statusMessage: `Setting "${key}" is too long.`,
42
+ })
43
+ }
44
+
44
45
  return {
45
- setting_name: key as SettingName,
46
+ setting_key: key,
46
47
  setting_value: value,
47
48
  }
48
49
  })
49
50
 
50
51
  const { data, error } = await client
51
52
  .from('settings')
52
- .upsert(transformed, { onConflict: 'setting_name' })
53
+ .upsert(transformed, { onConflict: 'setting_key' })
53
54
  .select()
54
55
 
55
56
  if (error) {
@@ -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
 
@@ -1,46 +1,19 @@
1
1
  import type { H3Event } from 'h3'
2
- import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
2
+ import { ALL_CAPABILITIES, requireCapability } from './capability-guard'
3
3
 
4
4
  /**
5
- * Guards a server route so only a logged-in admin can call it.
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.
5
+ * Guards a server route so only an admin (or a holder of every
6
+ * capability, which is what the built-in admin role grants) can call it.
7
+ * A thin alias over requireCapability, kept for the routes that ask for
8
+ * "admin, full stop" rather than one named capability.
19
9
  *
20
10
  * 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.
11
+ * an admin. Returns the claims on success. See capability-guard.ts for
12
+ * the user.sub vs user.id note: this function forwards straight into
13
+ * requireCapability, which never reads either field.
22
14
  */
23
15
  export async function requireAdmin(event: H3Event) {
24
- const user = await serverSupabaseUser(event)
25
-
26
- if (!user) {
27
- throw createError({ statusCode: 401, statusMessage: 'You must be logged in.' })
28
- }
29
-
30
- const client = await serverSupabaseClient<Database>(event)
31
-
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
- })
43
- }
44
-
45
- return user
16
+ return requireCapability(event, ALL_CAPABILITIES, {
17
+ message: 'Your account is not an admin.',
18
+ })
46
19
  }
@@ -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,30 +253,113 @@ 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
259
- setting_name: Database["public"]["Enums"]["tsettings"]
306
+ setting_key: string | null
307
+ setting_name: Database["public"]["Enums"]["tsettings"] | null
260
308
  setting_value: string
261
309
  }
262
310
  Insert: {
263
311
  id?: number
264
- setting_name: Database["public"]["Enums"]["tsettings"]
312
+ setting_key?: string | null
313
+ setting_name?: Database["public"]["Enums"]["tsettings"] | null
265
314
  setting_value: string
266
315
  }
267
316
  Update: {
268
317
  id?: number
269
- setting_name?: Database["public"]["Enums"]["tsettings"]
318
+ setting_key?: string | null
319
+ setting_name?: Database["public"]["Enums"]["tsettings"] | null
270
320
  setting_value?: string
271
321
  }
272
322
  Relationships: []
273
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
+ }
274
350
  }
275
351
  Views: {
276
352
  [_ in never]: never
277
353
  }
278
354
  Functions: {
279
- [_ 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
+ }
280
363
  }
281
364
  Enums: {
282
365
  thealthcheck: "first_setup"
@@ -1,125 +0,0 @@
1
- <script setup lang="ts">
2
- import { object, string } from 'yup'
3
-
4
- type Form = Partial<
5
- Record<
6
- Database['public']['Tables']['settings']['Insert']['setting_name'],
7
- string
8
- >
9
- >
10
-
11
- useHead({
12
- title: 'Settings',
13
- })
14
-
15
- const toast = useToast()
16
-
17
- const has_settings_modified = useState('has_settings_modified', () => {
18
- return Date.now()
19
- })
20
-
21
- const { data } = await useFetch('/api/settings')
22
-
23
- const schema = object({
24
- website_title: string().required().label('Website Title'),
25
- website_description: string().required().label('Website Description'),
26
- website_url: string().url().required().label('Website URL'),
27
- })
28
-
29
- const form = ref<Form>({
30
- website_title: data.value?.settings.website_title || '',
31
- website_description: data.value?.settings.website_description || '',
32
- website_url: data.value?.settings.website_url || '',
33
- })
34
-
35
- const isSubmitting = ref<boolean>(false)
36
-
37
- async function submitForm() {
38
- const payload = form.value
39
-
40
- isSubmitting.value = true
41
-
42
- try {
43
- await $fetch('/api/settings/update', {
44
- method: 'POST',
45
- body: payload,
46
- })
47
-
48
- toast.add({
49
- title: 'Settings updated successfully',
50
- color: 'success',
51
- })
52
-
53
- has_settings_modified.value = Date.now()
54
- } catch (error) {
55
- if (import.meta.dev) {
56
- console.error('Error updating settings:', error)
57
- }
58
-
59
- toast.add({
60
- title: 'Failed to update settings.',
61
- color: 'error',
62
- })
63
- } finally {
64
- isSubmitting.value = false
65
- }
66
- }
67
- </script>
68
-
69
- <template>
70
- <AdminView>
71
- <h1 class="text-3xl font-bold lg:text-4xl">Settings</h1>
72
-
73
- <UCard>
74
- <UForm :schema="schema" :state="form" @submit="submitForm">
75
- <div class="flex flex-col gap-y-6">
76
- <UFormField
77
- label="Website Title"
78
- name="website_title"
79
- class="w-full lg:w-1/2"
80
- >
81
- <UInput
82
- v-model="form.website_title"
83
- placeholder="e.g. My Website"
84
- />
85
- </UFormField>
86
-
87
- <UFormField
88
- label="Website Description"
89
- name="website_description"
90
- class="w-full lg:w-1/2"
91
- >
92
- <UInput
93
- v-model="form.website_description"
94
- placeholder="e.g. My Website Description"
95
- />
96
- </UFormField>
97
-
98
- <UFormField
99
- label="Website URL"
100
- name="website_url"
101
- class="w-full lg:w-1/2"
102
- >
103
- <UInput
104
- v-model="form.website_url"
105
- type="url"
106
- placeholder="e.g. https://example.com"
107
- />
108
- </UFormField>
109
-
110
- <div class="flex">
111
- <UButton
112
- :loading="isSubmitting"
113
- :disabled="isSubmitting"
114
- type="submit"
115
- icon="lucide:save"
116
- class="w-full justify-center sm:w-auto"
117
- >
118
- Save
119
- </UButton>
120
- </div>
121
- </div>
122
- </UForm>
123
- </UCard>
124
- </AdminView>
125
- </template>