@plutocms/supabase 0.11.0 → 0.11.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,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.11.2](https://github.com/plutocms/supabase/compare/v0.11.1...v0.11.2) (2026-10-22)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **db:** grant Data API access explicitly and restrict staff reads ([#93](https://github.com/plutocms/supabase/issues/93)) ([0ed2d56](https://github.com/plutocms/supabase/commit/0ed2d56c593ed6550312692e125ba75525f5fd69))
9
+ * **typegen:** keep the PostgREST version block and match the app's lint output ([#94](https://github.com/plutocms/supabase/issues/94)) ([38ec3ea](https://github.com/plutocms/supabase/commit/38ec3ea5e49ef559cfc8142bbbfff401f934cd22))
10
+
11
+ ## [0.11.1](https://github.com/plutocms/supabase/compare/v0.11.0...v0.11.1) (2026-10-21)
12
+
13
+
14
+ ### Bug Fixes
15
+
16
+ * **deps:** use @plutocms/pluto 0.17 ([#91](https://github.com/plutocms/supabase/issues/91)) ([7deeac5](https://github.com/plutocms/supabase/commit/7deeac5379ddaac0317fa457897190c0850e8ffc))
17
+ * **typegen:** keep the PostgREST version block and the app's format ([#90](https://github.com/plutocms/supabase/issues/90)) ([2518c33](https://github.com/plutocms/supabase/commit/2518c33f69fe188e69d93695819d7d3ca19932d7))
18
+
19
+
20
+ ### Performance Improvements
21
+
22
+ * **admin:** load the admin components on demand and start the setup check early ([#89](https://github.com/plutocms/supabase/issues/89)) ([f0d36bc](https://github.com/plutocms/supabase/commit/f0d36bc7d46f911522990653cd93ebb523c5a774))
23
+
3
24
  ## [0.11.0](https://github.com/plutocms/supabase/compare/v0.10.0...v0.11.0) (2026-10-20)
4
25
 
5
26
 
@@ -1,6 +1,16 @@
1
- import AuthSignIn from '../components/auth/AuthSignIn.vue'
2
- import MigrationsBanner from '../components/migrations/MigrationsBanner.vue'
3
- import SettingsGeneralPanel from '../components/settings/SettingsGeneralPanel.vue'
1
+ import { defineAsyncComponent } from 'vue'
2
+
3
+ // The three components load on demand. They stay out of the entry chunk. Core
4
+ // shows a skeleton while each chunk loads.
5
+ const AuthSignIn = defineAsyncComponent(
6
+ () => import('../components/auth/AuthSignIn.vue')
7
+ )
8
+ const MigrationsBanner = defineAsyncComponent(
9
+ () => import('../components/migrations/MigrationsBanner.vue')
10
+ )
11
+ const SettingsGeneralPanel = defineAsyncComponent(
12
+ () => import('../components/settings/SettingsGeneralPanel.vue')
13
+ )
4
14
 
5
15
  export default defineNuxtPlugin(() => {
6
16
  // Read at setup, while the Nuxt context exists. The driver below calls it
@@ -35,11 +45,7 @@ export default defineNuxtPlugin(() => {
35
45
  // again each time.
36
46
  const setupDone = useState<boolean>('supabase-setup-done', () => false)
37
47
 
38
- async function readSetupState(): Promise<SetupState> {
39
- if (setupDone.value) {
40
- return 'done'
41
- }
42
-
48
+ async function fetchSetupState(): Promise<SetupState> {
43
49
  let state: SetupState = 'unknown'
44
50
  try {
45
51
  state = setupStateFromResponse(
@@ -56,6 +62,27 @@ export default defineNuxtPlugin(() => {
56
62
  return state
57
63
  }
58
64
 
65
+ // On a cold load of an admin path, the request starts here, at plugin setup.
66
+ // The guard then awaits a request that is already in flight. The guard takes
67
+ // this promise once, so a later navigation reads the state again.
68
+ let earlyState: Promise<SetupState> | null =
69
+ import.meta.client &&
70
+ !setupDone.value &&
71
+ /^\/admin(?:\/|$)/.test(window.location.pathname)
72
+ ? fetchSetupState()
73
+ : null
74
+
75
+ async function readSetupState(): Promise<SetupState> {
76
+ if (setupDone.value) {
77
+ return 'done'
78
+ }
79
+
80
+ const early = earlyState
81
+ earlyState = null
82
+
83
+ return early ?? fetchSetupState()
84
+ }
85
+
59
86
  if (import.meta.client) {
60
87
  watch(
61
88
  [() => Boolean(session.value), hasSettingsModified],
@@ -0,0 +1,48 @@
1
+ -- Data API grants
2
+ --
3
+ -- From 2026-10-30, Supabase stops granting Data API access to new tables
4
+ -- in the public schema on its own. A table that a migration creates needs
5
+ -- an explicit grant, or the API answers "permission denied" (42501).
6
+ --
7
+ -- This file grants the minimal privileges that the RLS policies of this
8
+ -- layer already allow. It changes nothing on an older database: those
9
+ -- tables already hold broader privileges, and grant is additive. Every
10
+ -- statement is safe to run twice.
11
+ --
12
+ -- Rules: anon gets select only, and only on a table with a public read
13
+ -- policy. service_role gets select, insert, update and delete. No file
14
+ -- grants truncate, references, trigger or all.
15
+
16
+ -- healthcheck: the setup check reads it without a login.
17
+ grant select on public.healthcheck to anon;
18
+ grant select, update on public.healthcheck to authenticated;
19
+
20
+ -- settings: the policies allow select, insert and update.
21
+ grant select, insert, update on public.settings to authenticated;
22
+
23
+ -- profiles: select only. A write goes through a security definer function.
24
+ grant select on public.profiles to authenticated;
25
+
26
+ -- pluto_migrations: the admin reads the migration status.
27
+ grant select on public.pluto_migrations to authenticated;
28
+
29
+ -- Role tables: today's behavior. Every write policy needs public.is_admin().
30
+ grant select, insert, update, delete on public.roles to authenticated;
31
+ grant select, insert, update, delete on public.role_capabilities to authenticated;
32
+ grant select, insert, update, delete on public.user_roles to authenticated;
33
+
34
+ -- service_role: select, insert, update and delete on every table.
35
+ grant select, insert, update, delete on public.healthcheck to service_role;
36
+ grant select, insert, update, delete on public.settings to service_role;
37
+ grant select, insert, update, delete on public.profiles to service_role;
38
+ grant select, insert, update, delete on public.pluto_migrations to service_role;
39
+ grant select, insert, update, delete on public.roles to service_role;
40
+ grant select, insert, update, delete on public.role_capabilities to service_role;
41
+ grant select, insert, update, delete on public.user_roles to service_role;
42
+
43
+ -- Functions. Postgres already gives execute to public. These grants are
44
+ -- explicit, so the access does not depend on that default.
45
+ grant execute on function public.is_admin() to authenticated, service_role;
46
+ grant execute on function public.has_capability(text) to authenticated, service_role;
47
+ grant execute on function public.my_capabilities() to authenticated, service_role;
48
+ grant execute on function public.set_my_locale(text) to authenticated;
@@ -0,0 +1,58 @@
1
+ -- Restrict staff reads
2
+ --
3
+ -- Sign-up is open. Before this file, any signed-in user could read every
4
+ -- row of settings, pluto_migrations, roles and role_capabilities, because
5
+ -- the select policies used "using (true)". This file narrows them.
6
+ --
7
+ -- settings: a user who holds at least one capability (staff) reads them.
8
+ -- The admin bar loads the site name and URL for every staff user.
9
+ -- pluto_migrations: only an admin reads it. The migrations page is
10
+ -- admin only (requireAdmin -> public.is_admin()).
11
+ -- roles, role_capabilities: only an admin reads them. Only security
12
+ -- definer functions read them for other users.
13
+ --
14
+ -- dashboard_user keeps its access where it had access before. Every
15
+ -- statement is safe to run twice.
16
+
17
+ -- settings
18
+ drop policy if exists "Enable read access for authenticated users on settings" on public.settings;
19
+ drop policy if exists "Enable read access for staff on settings" on public.settings;
20
+
21
+ create policy "Enable read access for staff on settings"
22
+ on public.settings
23
+ for select
24
+ to authenticated, dashboard_user
25
+ using (
26
+ current_user = 'dashboard_user'
27
+ or exists (select 1 from public.my_capabilities())
28
+ );
29
+
30
+ -- pluto_migrations
31
+ drop policy if exists "Enable read access for authenticated users on pluto_migrations" on public.pluto_migrations;
32
+ drop policy if exists "Enable read access for admins on pluto_migrations" on public.pluto_migrations;
33
+
34
+ create policy "Enable read access for admins on pluto_migrations"
35
+ on public.pluto_migrations
36
+ for select
37
+ to authenticated, dashboard_user
38
+ using (current_user = 'dashboard_user' or public.is_admin());
39
+
40
+ -- roles
41
+ drop policy if exists "Enable read access for authenticated users on roles" on public.roles;
42
+ drop policy if exists "Enable read access for admins on roles" on public.roles;
43
+
44
+ create policy "Enable read access for admins on roles"
45
+ on public.roles
46
+ for select
47
+ to authenticated
48
+ using (public.is_admin());
49
+
50
+ -- role_capabilities
51
+ drop policy if exists "Enable read access for authenticated users on role_capabilities" on public.role_capabilities;
52
+ drop policy if exists "Enable read access for admins on role_capabilities" on public.role_capabilities;
53
+
54
+ create policy "Enable read access for admins on role_capabilities"
55
+ on public.role_capabilities
56
+ for select
57
+ to authenticated
58
+ using (public.is_admin());
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/supabase",
3
3
  "type": "module",
4
- "version": "0.11.0",
4
+ "version": "0.11.2",
5
5
  "trustedDependencies": [
6
6
  "@parcel/watcher",
7
7
  "@plutocms/pluto",
@@ -39,7 +39,7 @@
39
39
  "@nuxt/eslint": "^1.17.0",
40
40
  "@nuxt/ui": "^4.12.0",
41
41
  "@nuxtjs/supabase": "^2.1.0",
42
- "@plutocms/pluto": "^0.16.0",
42
+ "@plutocms/pluto": "^0.17.0",
43
43
  "@plutocms/utils": "^0.2.1",
44
44
  "postgres": "^3.4.9",
45
45
  "supabase": "^2.121.0",
@@ -1,6 +1,8 @@
1
1
  import { execFile } from 'node:child_process'
2
2
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
3
4
  import { join } from 'node:path'
5
+ import { pathToFileURL } from 'node:url'
4
6
  import { promisify } from 'node:util'
5
7
 
6
8
  const execFileAsync = promisify(execFile)
@@ -12,9 +14,103 @@ const execFileAsync = promisify(execFile)
12
14
  * directly via --db-url).
13
15
  */
14
16
  function resolveSupabaseBin(rootDir: string): string {
15
- const binName = process.platform === 'win32' ? 'supabase.cmd' : 'supabase'
16
- const localBin = join(rootDir, 'node_modules/.bin', binName)
17
- return existsSync(localBin) ? localBin : 'supabase'
17
+ // On Windows, npm installs `supabase.cmd` and Bun installs `supabase.exe`.
18
+ const binNames = process.platform === 'win32'
19
+ ? ['supabase.cmd', 'supabase.exe']
20
+ : ['supabase']
21
+ const localBin = binNames
22
+ .map((name) => join(rootDir, 'node_modules/.bin', name))
23
+ .find((path) => existsSync(path))
24
+ return localBin ?? 'supabase'
25
+ }
26
+
27
+ // The block, with its leading comment lines. The block has no nested braces.
28
+ const INTERNAL_BLOCK = /(?:^[ \t]*\/\/[^\n]*\n)*^[ \t]*__InternalSupabase:\s*\{[^{}]*\}[ \t]*\n/m
29
+ // The block itself. The name alone is not enough: the output also has it in
30
+ // `Omit<Database, '__InternalSupabase'>`.
31
+ const HAS_BLOCK = /^[ \t]*__InternalSupabase:\s*\{/m
32
+ const DATABASE_OPEN = /^export type Database = \{[ \t]*\n/m
33
+
34
+ /**
35
+ * Keeps the `__InternalSupabase` block of the existing types file.
36
+ *
37
+ * The `--db-url` mode of the Supabase CLI does not write this block. It
38
+ * holds the PostgREST version that supabase-js reads for its typings. When
39
+ * the new output has no block and the existing file has one, this function
40
+ * copies the existing block into the new output. In every other case it
41
+ * returns the new output as it is.
42
+ */
43
+ export function keepInternalSupabase(
44
+ next: string,
45
+ existing: string | null
46
+ ): string {
47
+ if (!existing || HAS_BLOCK.test(next)) {
48
+ return next
49
+ }
50
+
51
+ const block = existing.match(INTERNAL_BLOCK)?.[0]
52
+ const open = next.match(DATABASE_OPEN)
53
+
54
+ if (!block || !open || open.index === undefined) {
55
+ return next
56
+ }
57
+
58
+ const at = open.index + open[0].length
59
+ // The CLI writes a whitespace-only line after the opening brace. After the
60
+ // block it would stay as a blank line, so remove it.
61
+ const rest = next.slice(at).replace(/^(?:[ \t]*\n)+/, '')
62
+
63
+ return next.slice(0, at) + block + rest
64
+ }
65
+
66
+ /**
67
+ * Imports a package from the app's node_modules (resolved from `rootDir`).
68
+ */
69
+ async function importFromApp(rootDir: string, name: string) {
70
+ const entry = createRequire(join(rootDir, 'package.json')).resolve(name)
71
+ const mod = await import(pathToFileURL(entry).href)
72
+ return mod.default ?? mod
73
+ }
74
+
75
+ /**
76
+ * Fixes the text with the ESLint of the app. It resolves ESLint and the
77
+ * app's config from `rootDir`. It returns the text unchanged when the app
78
+ * has no ESLint, when ESLint ignores the file, or when the fix fails.
79
+ */
80
+ async function fixWithAppEslint(
81
+ rootDir: string,
82
+ filePath: string,
83
+ text: string
84
+ ): Promise<string> {
85
+ try {
86
+ const { ESLint } = await importFromApp(rootDir, 'eslint')
87
+ const eslint = new ESLint({ cwd: rootDir, fix: true })
88
+ const [result] = await eslint.lintText(text, { filePath })
89
+
90
+ return result?.output ?? text
91
+ } catch {
92
+ return text
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Formats the text with the Prettier of the app. It resolves Prettier from
98
+ * `rootDir`. It returns the text unchanged when the app has no Prettier or
99
+ * when the format fails.
100
+ */
101
+ async function formatWithAppPrettier(
102
+ rootDir: string,
103
+ filePath: string,
104
+ text: string
105
+ ): Promise<string> {
106
+ try {
107
+ const api = await importFromApp(rootDir, 'prettier')
108
+ const options = (await api.resolveConfig(filePath)) ?? {}
109
+
110
+ return await api.format(text, { ...options, filepath: filePath })
111
+ } catch {
112
+ return text
113
+ }
18
114
  }
19
115
 
20
116
  /**
@@ -56,11 +152,20 @@ export async function regenerateSupabaseTypes(
56
152
  ? readFileSync(targetPath, 'utf-8')
57
153
  : null
58
154
 
59
- if (existing === stdout) {
155
+ // The same steps as a usual `lint:fix`: ESLint first, then Prettier. So
156
+ // an unchanged schema gives the same text as the committed file.
157
+ const fixed = await fixWithAppEslint(
158
+ rootDir,
159
+ targetPath,
160
+ keepInternalSupabase(stdout, existing)
161
+ )
162
+ const processed = await formatWithAppPrettier(rootDir, targetPath, fixed)
163
+
164
+ if (existing === processed) {
60
165
  return
61
166
  }
62
167
 
63
- writeFileSync(targetPath, stdout, 'utf-8')
168
+ writeFileSync(targetPath, processed, 'utf-8')
64
169
  console.warn(`[typegen] Regenerated Supabase types at ${targetPath}`)
65
170
  } catch (error) {
66
171
  console.warn('[typegen] Failed to regenerate Supabase types:', error)