@plutocms/supabase 0.7.0 → 0.7.2

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.2](https://github.com/plutocms/supabase/compare/v0.7.1...v0.7.2) (2026-09-13)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **permissions:** make requireAdmin safe on a zero-migration database ([#58](https://github.com/plutocms/supabase/issues/58)) ([a83f6d0](https://github.com/plutocms/supabase/commit/a83f6d0bfa3ae150df5b90a06fcd29fe437ad9b7))
9
+
10
+ ## [0.7.1](https://github.com/plutocms/supabase/compare/v0.7.0...v0.7.1) (2026-09-13)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * **permissions:** break the migrations-page bootstrap deadlock ([#55](https://github.com/plutocms/supabase/issues/55)) ([19f4338](https://github.com/plutocms/supabase/commit/19f43388827ba1f4699a2347726e56084c06f8ef))
16
+
3
17
  ## [0.7.0](https://github.com/plutocms/supabase/compare/v0.6.0...v0.7.0) (2026-09-12)
4
18
 
5
19
 
@@ -30,6 +30,13 @@ const connectionForm = ref({
30
30
  })
31
31
 
32
32
  const isRunning = ref(false)
33
+ // True only while waitForServerAndRefresh is polling through a dev-server
34
+ // restart. useMigrations().status reads as null on every failed poll in
35
+ // that window (see its own doc comment), which would otherwise leave the
36
+ // page blank below the title — the toasts alone are easy to miss. The
37
+ // template shows a persistent banner instead, keyed on this flag, so the
38
+ // page always explains what is happening instead of going quiet.
39
+ const isReconnecting = ref(false)
33
40
  const lastRun = ref<Awaited<ReturnType<typeof runMigrations>> | null>(null)
34
41
  // Kept only in this page's own memory, only to show the manual .env step if
35
42
  // persisting fails — see EnvPersistWarning.vue. Never sent anywhere new.
@@ -118,8 +125,12 @@ async function applyMigrations(useForm: boolean) {
118
125
  color: 'info',
119
126
  })
120
127
 
128
+ isReconnecting.value = true
129
+
121
130
  const recovered = await waitForServerAndRefresh()
122
131
 
132
+ isReconnecting.value = false
133
+
123
134
  toast.add(
124
135
  recovered
125
136
  ? {
@@ -163,7 +174,19 @@ async function applyMigrations(useForm: boolean) {
163
174
  <AdminView>
164
175
  <h1 class="text-3xl font-bold lg:text-4xl">Migrations</h1>
165
176
 
166
- <div v-if="fetchStatus === 'pending'" class="flex items-center gap-x-2">
177
+ <UAlert
178
+ v-if="isReconnecting"
179
+ color="info"
180
+ variant="outline"
181
+ icon="lucide:refresh-cw"
182
+ title="Dev server restarting"
183
+ description="Saving a new connection string restarts the dev server. Reconnecting — this page will update on its own once it's back."
184
+ />
185
+
186
+ <div
187
+ v-else-if="fetchStatus === 'pending'"
188
+ class="flex items-center gap-x-2"
189
+ >
167
190
  <Icon name="svg-spinners:ring-resize" />
168
191
  <span>Loading migration status…</span>
169
192
  </div>
@@ -340,5 +363,25 @@ async function applyMigrations(useForm: boolean) {
340
363
  </div>
341
364
  </UCard>
342
365
  </template>
366
+
367
+ <UAlert
368
+ v-else
369
+ color="error"
370
+ variant="outline"
371
+ icon="lucide:circle-x"
372
+ title="Could not load migration status"
373
+ description="This isn't the dev-server-restart case above — something else went wrong reading migration status. Check the server console, then try again."
374
+ >
375
+ <template #actions>
376
+ <UButton
377
+ color="error"
378
+ variant="outline"
379
+ icon="lucide:refresh-cw"
380
+ @click="refresh()"
381
+ >
382
+ Retry
383
+ </UButton>
384
+ </template>
385
+ </UAlert>
343
386
  </AdminView>
344
387
  </template>
@@ -8,19 +8,11 @@ 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
- )
11
+ // Loading/clearing permissions on login/logout lives in
12
+ // app/plugins/pluto-permissions-sync.ts now, not here — that plugin runs
13
+ // on every page, not just this one, so a signed-in admin browsing a public
14
+ // page directly (no prior visit to /admin/** this session) still gets
15
+ // their capabilities loaded.
24
16
 
25
17
  const visibility = useDocumentVisibility()
26
18
 
@@ -36,10 +36,12 @@ 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.
39
42
  capabilities: [
40
43
  { id: 'settings-manage', key: 'settings:manage', label: 'Manage site settings' },
41
44
  { id: 'users-read', key: 'users:read', label: 'View all user accounts' },
42
- { id: 'system-migrate', key: 'system:migrate', label: 'Apply pending layer migrations' },
43
45
  ],
44
46
  permissionsDriver: {
45
47
  id: 'supabase',
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Loads (or clears) the current user's capabilities whenever their session
3
+ * appears or disappears — app-wide, not page-scoped.
4
+ *
5
+ * This used to live as a `watch` inside `app/pages/admin.vue`. That only
6
+ * ran while `admin.vue` was the mounted page, so a signed-in admin loading
7
+ * a public page directly (a fresh navigation or a page reload, with no
8
+ * earlier visit to `/admin/**` in the same app instance) never triggered a
9
+ * load at all — `usePlutoPermissions().granted` stayed empty, and every
10
+ * `can()` check for that admin read as `false` on that page, even though
11
+ * the same admin's capability-gated UI worked correctly inside `/admin/**`.
12
+ * `NavbarAdminActions.vue`'s "Edit product" quick-link (shown while
13
+ * viewing a live `/product/**` page) is exactly the kind of capability-
14
+ * gated UI that lives outside `/admin/**` by design, so it needs this to
15
+ * fire on every page, not just admin ones.
16
+ *
17
+ * `useSupabaseSession()` (not `useAuth()`) on purpose: it is the same
18
+ * primitive `useAuth()`'s own `isLoggedIn` is built from
19
+ * (`computed(() => !!supabaseSession.value)`), and it is genuinely
20
+ * app-wide reactive state already (managed by `@nuxtjs/supabase`'s own
21
+ * plugin), so reading it directly here needs no extra composable surface.
22
+ */
23
+ export default defineNuxtPlugin(() => {
24
+ const session = useSupabaseSession()
25
+ const { load, clear } = usePlutoPermissions()
26
+
27
+ watch(
28
+ session,
29
+ (current) => {
30
+ if (current) {
31
+ load()
32
+ } else {
33
+ clear()
34
+ }
35
+ },
36
+ { immediate: true }
37
+ )
38
+ })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/supabase",
3
3
  "type": "module",
4
- "version": "0.7.0",
4
+ "version": "0.7.2",
5
5
  "trustedDependencies": [
6
6
  "@parcel/watcher",
7
7
  "@plutocms/pluto",
@@ -1,6 +1,6 @@
1
1
  import type { PlutoMigrationFile } from '../../../shared/types/migrations'
2
2
  import type { MigrationFileResult } from '../../utils/migrations'
3
- import { requireCapability } from '../../utils/capability-guard'
3
+ import { requireAdmin } from '../../utils/admin-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,10 @@ interface Payload {
11
11
  }
12
12
 
13
13
  export default defineEventHandler(async (event) => {
14
- await requireCapability(event, 'system:migrate')
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.
17
+ await requireAdmin(event)
15
18
 
16
19
  const body = await readBody<Payload | undefined>(event)
17
20
 
@@ -1,8 +1,11 @@
1
- import { requireCapability } from '../../utils/capability-guard'
1
+ import { requireAdmin } from '../../utils/admin-guard'
2
2
  import { getMigrationStatus } from '../../utils/pending-migrations'
3
3
 
4
4
  export default defineEventHandler(async (event) => {
5
- await requireCapability(event, 'system:migrate')
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.
8
+ await requireAdmin(event)
6
9
 
7
10
  const status = await getMigrationStatus(event)
8
11
 
@@ -17,7 +17,18 @@ export default defineEventHandler(async (event) => {
17
17
  const { data, error } = await client.rpc('my_capabilities')
18
18
 
19
19
  if (error) {
20
- throw createError({ statusCode: 500, statusMessage: error.message })
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[] }
21
32
  }
22
33
 
23
34
  return { capabilities: data ?? [] }
@@ -1,19 +1,71 @@
1
1
  import type { H3Event } from 'h3'
2
- import { ALL_CAPABILITIES, requireCapability } from './capability-guard'
2
+ import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
3
3
 
4
4
  /**
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.
5
+ * Guards a server route so only an admin can call it.
6
+ *
7
+ * Queries `public.profiles.is_admin` directly deliberately NOT
8
+ * `public.is_admin()` or `public.has_capability()`. Both of those are
9
+ * defined by migrations (`002_admin_hardening.sql`,
10
+ * `004_roles_and_capabilities.sql` respectively); a genuinely fresh site
11
+ * that has never applied any migration beyond the original baseline
12
+ * schema — an expected, normal state, not a broken one — has neither
13
+ * function. `/api/migrations/*` (the only routes that call `requireAdmin`
14
+ * today) are the one place this actually bites: they are the routes that
15
+ * apply a pending migration, so they must work with zero migrations
16
+ * applied, not just with `002`+ already in place. An earlier version of
17
+ * this function called `public.is_admin()` on the theory that it "always
18
+ * exists" — true for a site that has applied at least `002`, false for a
19
+ * fresh one that has not, which is exactly the case that matters here.
20
+ * `profiles.is_admin` (the column) is part of the original baseline
21
+ * schema itself, before any of this project's layered migrations existed,
22
+ * so querying it directly has no migration dependency at all.
23
+ *
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
26
+ * hand"), with `profiles.is_admin` left `false`, cannot pass this check —
27
+ * only RLS and `has_capability()`-gated routes recognize a role-only
28
+ * admin. That is an acceptable, narrow limitation: `user_roles` cannot
29
+ * exist before `004` has been applied, so a role-only admin can only
30
+ * exist on a site that has already applied every current migration, at
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.
34
+ *
35
+ * `serverSupabaseUser` (from `@nuxtjs/supabase`, backed by
36
+ * `client.auth.getClaims()`) returns decoded JWT claims, not a Supabase
37
+ * `User` row — the claims object has no `id` field, only `sub`. Querying
38
+ * `.eq('id', user.id)` silently matches zero rows and looks exactly like
39
+ * "not an admin" even for a real admin. Always read `user.sub`.
9
40
  *
10
41
  * Throws a 401 if there is no logged-in user, or a 403 if the user is not
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.
42
+ * an admin. Returns the user's claims on success.
14
43
  */
15
44
  export async function requireAdmin(event: H3Event) {
16
- return requireCapability(event, ALL_CAPABILITIES, {
17
- message: 'Your account is not an admin.',
18
- })
45
+ const user = await serverSupabaseUser(event)
46
+
47
+ if (!user) {
48
+ throw createError({ statusCode: 401, statusMessage: 'You must be logged in.' })
49
+ }
50
+
51
+ const client = await serverSupabaseClient<Database>(event)
52
+ const { data: profile, error } = await client
53
+ .from('profiles')
54
+ .select('is_admin')
55
+ .eq('id', user.sub)
56
+ .single()
57
+
58
+ if (error) {
59
+ // Logged, never returned: the 403 below must not tell an untrusted
60
+ // caller whether it hit a real error (a missing column, a connection
61
+ // problem) or a genuine non-admin. Server logs are where that
62
+ // distinction has to live instead.
63
+ console.error('requireAdmin: profiles lookup failed:', error.message)
64
+ }
65
+
66
+ if (error || !profile?.is_admin) {
67
+ throw createError({ statusCode: 403, statusMessage: 'Your account is not an admin.' })
68
+ }
69
+
70
+ return user
19
71
  }
Binary file
Binary file
@@ -360,6 +360,10 @@ export type Database = {
360
360
  Args: Record<PropertyKey, never>
361
361
  Returns: string[]
362
362
  }
363
+ is_admin: {
364
+ Args: Record<PropertyKey, never>
365
+ Returns: boolean
366
+ }
363
367
  }
364
368
  Enums: {
365
369
  thealthcheck: "first_setup"