@plutocms/supabase 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/FEATURES.md +3 -0
  3. package/app/components/EnvPersistWarning.vue +82 -0
  4. package/app/components/migrations/MigrationsBanner.vue +26 -0
  5. package/app/components/navbar/NavbarAdminProvider.vue +4 -0
  6. package/app/composables/auth.ts +11 -9
  7. package/app/composables/migrations.ts +87 -0
  8. package/app/middleware/setup-check.ts +8 -1
  9. package/app/pages/admin/migrations.vue +344 -0
  10. package/app/pages/admin/setup.vue +109 -7
  11. package/app/pages/admin.vue +13 -0
  12. package/db/migrations/002_admin_hardening.sql +107 -0
  13. package/modules/pluto-migrations.ts +167 -32
  14. package/nuxt.config.ts +3 -1
  15. package/package.json +2 -2
  16. package/server/api/migrations/run.post.ts +101 -0
  17. package/server/api/migrations/status.get.ts +14 -0
  18. package/server/api/settings/update.post.ts +34 -6
  19. package/server/api/setup/create.post.ts +82 -54
  20. package/server/api/users/[id].get.ts +3 -0
  21. package/server/api/users/index.get.ts +3 -0
  22. package/server/plugins/migrations.ts +35 -38
  23. package/server/utils/admin-guard.ts +46 -0
  24. package/server/utils/env-file.ts +53 -0
  25. package/server/utils/ledger.ts +124 -0
  26. package/server/utils/migrations.ts +0 -0
  27. package/server/utils/pending-migrations.ts +0 -0
  28. package/server/utils/scrub-connection-string.ts +37 -0
  29. package/server/utils/sql.ts +86 -11
  30. package/shared/types/migrations.d.ts +28 -0
  31. package/shared/types/runtime-config.d.ts +10 -8
  32. package/shared/types/supabase.ts +7 -12
  33. /package/{public/schema.sql → db/migrations/001_baseline.sql} +0 -0
@@ -17,6 +17,8 @@ const projectId = config.public.supabase.url
17
17
 
18
18
  const toast = useToast()
19
19
 
20
+ const isDev = import.meta.dev
21
+
20
22
  const items = ref<StepperItem[]>([
21
23
  {
22
24
  title: 'Database Setup',
@@ -40,6 +42,12 @@ const databaseForm = ref({
40
42
  password: '',
41
43
  })
42
44
 
45
+ // Set only when the server reports it could not write DATABASE_URL to
46
+ // .env — see EnvPersistWarning.vue. Holds the connection string this page
47
+ // already sent once; never re-sent, only shown back on this same page.
48
+ const envNotPersisted = ref(false)
49
+ const submittedConnectionString = ref('')
50
+
43
51
  const currentLoading = computed(() => {
44
52
  if (currentStep.value === 0) {
45
53
  return isSettingUpDatabase.value
@@ -64,18 +72,55 @@ const currentStepSubmitLabel = computed(() => {
64
72
  return 'Submit'
65
73
  })
66
74
 
75
+ /**
76
+ * Polls /api/settings/first_setup until the dev server responds again, and
77
+ * advances the stepper once it does.
78
+ *
79
+ * Used only to wait out the restart described in the `catch` block of
80
+ * `completeDatabaseSetup` — the server is expected to come back within a
81
+ * few seconds, not to be actually down.
82
+ */
83
+ async function waitForServerAndAdvance(): Promise<boolean> {
84
+ const maxAttempts = 15
85
+ const delayMs = 2000
86
+
87
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
88
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
89
+
90
+ try {
91
+ await $fetch('/api/settings/first_setup')
92
+
93
+ toast.add({
94
+ title: 'Reconnected',
95
+ description: 'The server is back. Your database has been set up.',
96
+ icon: 'lucide:check-circle',
97
+ color: 'success',
98
+ })
99
+
100
+ stepper.value?.next()
101
+
102
+ return true
103
+ } catch {
104
+ // Still restarting — try again.
105
+ }
106
+ }
107
+
108
+ return false
109
+ }
110
+
67
111
  async function completeDatabaseSetup() {
68
112
  try {
69
113
  isSettingUpDatabase.value = true
70
114
 
115
+ const connectionString = databaseForm.value.connectionString.replace(
116
+ '[YOUR-PASSWORD]',
117
+ encodeURIComponent(databaseForm.value.password)
118
+ )
119
+
71
120
  const data = await $fetch<any>('/api/setup/create', {
72
121
  method: 'POST',
73
122
  body: {
74
- baseUrl: window.location.origin,
75
- connectionString: databaseForm.value.connectionString.replace(
76
- '[YOUR-PASSWORD]',
77
- encodeURIComponent(databaseForm.value.password)
78
- ),
123
+ connectionString,
79
124
  },
80
125
  })
81
126
 
@@ -90,6 +135,11 @@ async function completeDatabaseSetup() {
90
135
  return
91
136
  }
92
137
 
138
+ if (data.persisted === false) {
139
+ envNotPersisted.value = true
140
+ submittedConnectionString.value = connectionString
141
+ }
142
+
93
143
  toast.add({
94
144
  title: 'Database setup complete',
95
145
  description: 'Your database has been set up successfully.',
@@ -99,6 +149,44 @@ async function completeDatabaseSetup() {
99
149
 
100
150
  stepper.value?.next()
101
151
  } catch (error) {
152
+ // Writing DATABASE_URL to .env (the last step of /api/setup/create,
153
+ // after the schema and every layer migration already succeeded)
154
+ // restarts the dev server. The response can be cut off by that
155
+ // restart even though the setup itself worked. A dropped connection
156
+ // has no HTTP status code — a real error response would — so use
157
+ // that, and only in dev, to tell "still finishing" apart from a real
158
+ // failure. See the matching comment in app/pages/admin/migrations.vue.
159
+ const hasStatusCode =
160
+ typeof error === 'object' &&
161
+ error !== null &&
162
+ 'statusCode' in error &&
163
+ (error as { statusCode?: unknown }).statusCode !== undefined
164
+
165
+ if (import.meta.dev && !hasStatusCode) {
166
+ toast.add({
167
+ title: 'Dev server restarting',
168
+ description: 'Setup is finishing. Reconnecting…',
169
+ icon: 'lucide:refresh-cw',
170
+ color: 'info',
171
+ })
172
+
173
+ const recovered = await waitForServerAndAdvance()
174
+
175
+ if (recovered) {
176
+ return
177
+ }
178
+
179
+ toast.add({
180
+ title: 'Still reconnecting',
181
+ description:
182
+ 'The dev server is taking longer than expected. Reload this page in a moment.',
183
+ icon: 'lucide:triangle-alert',
184
+ color: 'warning',
185
+ })
186
+
187
+ return
188
+ }
189
+
102
190
  if (import.meta.dev) {
103
191
  console.error('Error setting up the database:', error)
104
192
  }
@@ -234,11 +322,11 @@ function handleStepChange(step: number) {
234
322
  Supabase database and run the SQL queries from
235
323
  this
236
324
  <ULink
237
- to="https://github.com/plutocms/supabase/blob/feat/setup-wizard/public/schema.sql"
325
+ to="https://github.com/plutocms/supabase/tree/main/db/migrations"
238
326
  target="_blank"
239
327
  class="underline"
240
328
  >
241
- SQL file</ULink
329
+ SQL files</ULink
242
330
  >.
243
331
  </p>
244
332
 
@@ -253,6 +341,15 @@ function handleStepChange(step: number) {
253
341
  </div>
254
342
  </template>
255
343
  </UAlert>
344
+
345
+ <UAlert
346
+ v-if="isDev"
347
+ color="info"
348
+ variant="outline"
349
+ title="This restarts the dev server"
350
+ icon="lucide:refresh-cw"
351
+ description="Saving your connection string writes it to .env. In development, that restarts the server automatically. The page will briefly show a reconnecting message — this is expected, not an error."
352
+ />
256
353
  </UForm>
257
354
  </section>
258
355
 
@@ -269,6 +366,11 @@ function handleStepChange(step: number) {
269
366
  up.
270
367
  </p>
271
368
 
369
+ <EnvPersistWarning
370
+ v-if="envNotPersisted"
371
+ :connection-string="submittedConnectionString"
372
+ />
373
+
272
374
  <p class="text-center">
273
375
  <UButton
274
376
  icon="lucide:arrow-right"
@@ -32,6 +32,19 @@ watch(visibility, async (current, previous) => {
32
32
 
33
33
  <template>
34
34
  <div class="light:text-zinc-800 h-full dark:text-white">
35
+ <ClientOnly>
36
+ <Container
37
+ v-if="
38
+ isLoggedIn
39
+ && !route.path.startsWith('/admin/setup')
40
+ && !route.path.startsWith('/admin/migrations')
41
+ "
42
+ class="pt-4"
43
+ >
44
+ <MigrationsBanner />
45
+ </Container>
46
+ </ClientOnly>
47
+
35
48
  <NuxtPage />
36
49
  </div>
37
50
  </template>
@@ -0,0 +1,107 @@
1
+ -- Admin hardening
2
+ --
3
+ -- Gates the writes that were previously open to any authenticated user
4
+ -- behind a real admin check, and closes a client-controlled admin
5
+ -- self-grant on signup. Standalone and self-sufficient: it does not
6
+ -- depend on running as a diff against 001_baseline.sql, only on that
7
+ -- file's tables (public.profiles, public.settings, public.healthcheck)
8
+ -- already existing.
9
+
10
+ -- Returns true when the calling user's own profile has is_admin = true.
11
+ -- SECURITY DEFINER so this lookup runs with the function owner's
12
+ -- privileges rather than the caller's — RLS policies call this to decide
13
+ -- whether to grant a *wider* read/write than a user's own row would
14
+ -- otherwise get, so the check itself must not be subject to those same
15
+ -- policies.
16
+ create or replace function public.is_admin()
17
+ returns boolean
18
+ language sql
19
+ security definer
20
+ set search_path = ''
21
+ stable
22
+ as $$
23
+ select coalesce(
24
+ (select is_admin from public.profiles where id = auth.uid()),
25
+ false
26
+ );
27
+ $$;
28
+
29
+ -- A user can always read their own profile; only admins can read
30
+ -- everyone's. Previously any authenticated user could read every row
31
+ -- (including every user's email) — this was the actual reason `is_admin`
32
+ -- existed on the table but no policy referenced it.
33
+ drop policy if exists "Enable read access for authenticated users" on public.profiles;
34
+
35
+ create policy "Enable read access for authenticated users"
36
+ on public.profiles
37
+ for select
38
+ to authenticated, dashboard_user
39
+ using (auth.uid() = id or public.is_admin());
40
+
41
+ -- Previously any authenticated user (not just an admin) could insert or
42
+ -- update settings — see server/api/settings/update.post.ts, which also
43
+ -- now requires an admin session.
44
+ drop policy if exists "Enable insert for authenticated users only" on public.settings;
45
+
46
+ create policy "Enable insert for authenticated users only"
47
+ on public.settings
48
+ for insert
49
+ to authenticated, dashboard_user
50
+ with check (public.is_admin());
51
+
52
+ drop policy if exists "Enable update for authenticated users on settings" on public.settings;
53
+
54
+ create policy "Enable update for authenticated users on settings"
55
+ on public.settings
56
+ for update
57
+ to authenticated, dashboard_user
58
+ using (public.is_admin())
59
+ with check (public.is_admin());
60
+
61
+ -- Previously any authenticated user (not just an admin) could flip
62
+ -- first_setup back to 'true', which would re-open the unauthenticated
63
+ -- setup wizard for every visitor.
64
+ drop policy if exists "Enable update for authenticated users on healthcheck" on public.healthcheck;
65
+
66
+ create policy "Enable update for authenticated users on healthcheck"
67
+ on public.healthcheck
68
+ for update
69
+ to authenticated, dashboard_user
70
+ using (public.is_admin())
71
+ with check (public.is_admin());
72
+
73
+ --- inserts a row into public.profiles
74
+ --- automatically grants admin to the first user if no profiles exist yet
75
+ create or replace function public.handle_new_user()
76
+ returns trigger
77
+ language plpgsql
78
+ security definer set search_path = ''
79
+ as $$
80
+ declare
81
+ profile_count int;
82
+ should_be_admin boolean;
83
+ begin
84
+ select count(*) into profile_count from public.profiles;
85
+
86
+ -- Only the very first account is auto-promoted. Never trust
87
+ -- raw_user_meta_data for this: it is fully client-controlled — anyone can
88
+ -- pass an arbitrary `options.data` straight to Supabase's own
89
+ -- /auth/v1/signup endpoint, bypassing this project's own /api/auth/signup
90
+ -- route entirely — so honoring an `is_admin` key there let any new
91
+ -- signup grant itself admin.
92
+ should_be_admin := profile_count = 0;
93
+
94
+ insert into public.profiles (id, email, username, display_name, is_admin)
95
+ values (
96
+ new.id,
97
+ new.email,
98
+ new.raw_user_meta_data ->> 'username',
99
+ new.raw_user_meta_data ->> 'display_name',
100
+ should_be_admin
101
+ );
102
+ return new;
103
+ end;
104
+ $$;
105
+ -- Not re-created here: on_auth_user_created (from 001_baseline.sql)
106
+ -- already points at public.handle_new_user() by name, and
107
+ -- `create or replace function` above is enough to update its behavior.
@@ -1,11 +1,87 @@
1
+ import type { PlutoMigrationFile } from '../shared/types/migrations'
2
+ import { createHash } from 'node:crypto'
1
3
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
2
- import { dirname, join, relative } from 'node:path'
4
+ import { basename, dirname, join, relative } from 'node:path'
3
5
  import { createResolver, defineNuxtModule, getLayerDirectories } from 'nuxt/kit'
4
6
 
7
+ const schemaPattern = /^schema\.(.+)\.sql$/
8
+ const noTransactionPattern = /^--\s*pluto:no-transaction\b/
9
+
10
+ /**
11
+ * Plain, deliberate string comparator for migration file names — ASCII
12
+ * lexicographic order, so `001_...` sorts before `002_...` and so on.
13
+ * Written out instead of relying on `Array.prototype.sort()`'s default
14
+ * (which happens to do the same thing for ASCII names, but isn't a
15
+ * documented guarantee to depend on).
16
+ */
17
+ function compareFileNames(a: string, b: string): number {
18
+ if (a < b) {
19
+ return -1
20
+ }
21
+ if (a > b) {
22
+ return 1
23
+ }
24
+ return 0
25
+ }
26
+
27
+ /**
28
+ * Derives a layer's key from its `package.json` `name` field, stripping
29
+ * any `@scope/` prefix. `@plutocms/supabase` always maps to the literal
30
+ * key `core` — every deployed site's ledger already has rows with
31
+ * `layer_name = 'core'`. Falls back to the layer directory's own name
32
+ * when `package.json` is missing or unreadable.
33
+ */
34
+ function deriveLayerKey(layerRoot: string): string {
35
+ try {
36
+ const pkgRaw = readFileSync(join(layerRoot, 'package.json'), 'utf-8')
37
+ const pkg = JSON.parse(pkgRaw) as { name?: string }
38
+
39
+ if (typeof pkg.name === 'string' && pkg.name.length > 0) {
40
+ if (pkg.name === '@plutocms/supabase') {
41
+ return 'core'
42
+ }
43
+ return pkg.name.replace(/^@[^/]+\//, '')
44
+ }
45
+ } catch {
46
+ // package.json missing or unreadable — fall back to the directory name.
47
+ }
48
+
49
+ return basename(layerRoot)
50
+ }
51
+
52
+ function computeChecksum(content: string): string {
53
+ return createHash('sha256').update(content, 'utf-8').digest('hex').slice(0, 16)
54
+ }
55
+
5
56
  /**
6
- * Discovers schema.[layerName].sql files across all Nuxt layers,
7
- * reads their content at build time, and populates runtimeConfig
8
- * so the migrations plugin can run them at server startup.
57
+ * True when the file's first non-blank line is a `-- pluto:no-transaction`
58
+ * directive.
59
+ */
60
+ function detectNoTransaction(content: string): boolean {
61
+ const firstNonBlankLine = content
62
+ .split('\n')
63
+ .map((line) => line.trim())
64
+ .find((line) => line.length > 0)
65
+
66
+ return firstNonBlankLine ? noTransactionPattern.test(firstNonBlankLine) : false
67
+ }
68
+
69
+ function readMigrationFile(dir: string, fileName: string, name: string): PlutoMigrationFile {
70
+ const content = readFileSync(join(dir, fileName), 'utf-8')
71
+
72
+ return {
73
+ name,
74
+ sql: content,
75
+ checksum: computeChecksum(content),
76
+ noTransaction: detectNoTransaction(content),
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Discovers `db/migrations/*.sql` (or, for a layer not yet converted, the
82
+ * legacy `public/schema.sql` / `public/schema.[name].sql`) across all Nuxt
83
+ * layers, reads their content at build time, and populates runtimeConfig
84
+ * so the migrations engine can apply them at server startup.
9
85
  *
10
86
  * Also seeds the consuming app's shared/types/supabase.ts with a
11
87
  * re-export of this layer's own committed snapshot when the consumer
@@ -59,62 +135,121 @@ export default defineNuxtModule({
59
135
  }
60
136
 
61
137
  const layerDirs = getLayerDirectories()
62
- const layerSchemas: Record<string, string> = {}
63
- const schemaPattern = /^schema\.(.+)\.sql$/
138
+ const layerMigrations: Record<string, PlutoMigrationFile[]> = {}
139
+
140
+ // Keeps the first-discovered layer for a given key (getLayerDirectories
141
+ // orders the user/project layer first), and warns once when a later
142
+ // layer's key collides with one already discovered.
143
+ function assignLayer(layerKey: string, files: PlutoMigrationFile[]): void {
144
+ if (layerMigrations[layerKey]) {
145
+ console.warn(
146
+ `[pluto-migrations] Layer key "${layerKey}" was already discovered from another layer. Keeping the first one, ignoring this one.`
147
+ )
148
+ return
149
+ }
150
+ layerMigrations[layerKey] = files
151
+ }
64
152
 
65
153
  for (const layer of layerDirs) {
66
- // Each layer may have a public/ directory with schema files
154
+ const migrationsDir = join(layer.root, 'db/migrations')
155
+
156
+ if (existsSync(migrationsDir)) {
157
+ let fileNames: string[] = []
158
+ try {
159
+ fileNames = readdirSync(migrationsDir).filter((file) =>
160
+ file.endsWith('.sql')
161
+ )
162
+ } catch {
163
+ console.error(
164
+ `[pluto-migrations] Failed to read migrations directory: ${migrationsDir}`
165
+ )
166
+ continue
167
+ }
168
+
169
+ fileNames.sort(compareFileNames)
170
+
171
+ const files: PlutoMigrationFile[] = []
172
+ for (const fileName of fileNames) {
173
+ try {
174
+ files.push(readMigrationFile(migrationsDir, fileName, fileName))
175
+ } catch {
176
+ console.error(
177
+ `[pluto-migrations] Failed to read migration file: ${fileName}`
178
+ )
179
+ }
180
+ }
181
+
182
+ assignLayer(deriveLayerKey(layer.root), files)
183
+ continue
184
+ }
185
+
186
+ // Legacy fallback for a layer not yet converted to db/migrations/,
187
+ // exactly as before: a single public/schema.sql (always key `core`)
188
+ // and/or public/schema.[name].sql (key comes from the file name, not
189
+ // package.json — this keeps working for an unconverted layer without
190
+ // requiring it to match its package name).
67
191
  const publicDir = join(layer.root, 'public')
68
192
 
69
193
  if (!existsSync(publicDir)) {
70
194
  continue
71
195
  }
72
196
 
73
- let files: string[] = []
197
+ let publicFiles: string[] = []
74
198
  try {
75
- files = readdirSync(publicDir, 'utf-8')
199
+ publicFiles = readdirSync(publicDir)
76
200
  } catch {
77
201
  continue
78
202
  }
79
203
 
80
- for (const file of files) {
81
- // Skip the core schema
82
- if (file === 'schema.sql') {
204
+ for (const fileName of publicFiles) {
205
+ if (fileName === 'schema.sql') {
206
+ try {
207
+ assignLayer('core', [
208
+ readMigrationFile(publicDir, fileName, '001_baseline.sql'),
209
+ ])
210
+ } catch {
211
+ console.error(
212
+ `[pluto-migrations] Failed to read schema file: ${fileName}`
213
+ )
214
+ }
83
215
  continue
84
216
  }
85
217
 
86
- const match = file.match(schemaPattern)
218
+ const match = fileName.match(schemaPattern)
87
219
  if (match?.[1]) {
88
- const layerName = match[1]
89
- // Read SQL content at build time
90
220
  try {
91
- layerSchemas[layerName] = readFileSync(
92
- join(publicDir, file),
93
- 'utf-8'
94
- )
221
+ assignLayer(match[1], [
222
+ readMigrationFile(publicDir, fileName, '001_baseline.sql'),
223
+ ])
95
224
  } catch {
96
225
  console.error(
97
- `[pluto-migrations] Failed to read schema file: ${file}`
226
+ `[pluto-migrations] Failed to read schema file: ${fileName}`
98
227
  )
99
228
  }
100
229
  }
101
230
  }
102
231
  }
103
232
 
104
- const layerNames = Object.keys(layerSchemas)
105
-
106
- // Populate runtimeConfig with layer names and their SQL content.
233
+ // Populate runtimeConfig with every layer's ordered migration files.
107
234
  // Nuxt's schema inference narrows this to a literal shape based on
108
- // whatever layer keys were last observed in this project (see the
109
- // `RuntimeConfig` augmentation in shared/types/runtime-config.d.ts,
110
- // which isn't visible from this module-context tsconfig), so the
111
- // assignment is cast to the intended general shape.
112
- ;(nuxt.options.runtimeConfig as { plutoLayerSchemas: Record<string, string> }).plutoLayerSchemas =
113
- layerSchemas
114
-
115
- if (layerNames.length > 0) {
235
+ // whatever layer keys and file shapes happen to be discovered in a
236
+ // given project (see the `RuntimeConfig` augmentation in
237
+ // shared/types/runtime-config.d.ts, which isn't visible from this
238
+ // module-context tsconfig), so the assignment is cast to the intended
239
+ // general shape. That inferred shape's array elements don't carry
240
+ // enough structure for a direct cast, hence the `unknown` step.
241
+ ;(
242
+ nuxt.options.runtimeConfig as unknown as {
243
+ plutoLayerMigrations: Record<string, PlutoMigrationFile[]>
244
+ }
245
+ ).plutoLayerMigrations = layerMigrations
246
+
247
+ const layerKeys = Object.keys(layerMigrations)
248
+ if (layerKeys.length > 0) {
116
249
  console.warn(
117
- `[pluto-migrations] Discovered layer schemas: ${layerNames.join(', ')}`
250
+ `[pluto-migrations] Discovered layer migrations: ${layerKeys
251
+ .map((key) => `${key} (${layerMigrations[key]!.length} file(s))`)
252
+ .join(', ')}`
118
253
  )
119
254
  }
120
255
  },
package/nuxt.config.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { PlutoMigrationFile } from './shared/types/migrations'
2
+
1
3
  // Point these at a local checkout (e.g. `../pluto`, `../utils`) to test
2
4
  // unpublished changes; unset, they resolve to the published npm packages.
3
5
  const plutoLayer = process.env.PLUTO_PLUTO_PATH || '@plutocms/pluto'
@@ -18,7 +20,7 @@ export default defineNuxtConfig({
18
20
  supabaseUrl: process.env.SUPABASE_URL,
19
21
  supabaseKey: process.env.SUPABASE_KEY,
20
22
  plutoRootDir: '',
21
- plutoLayerSchemas: {} as Record<string, string>,
23
+ plutoLayerMigrations: {} as Record<string, PlutoMigrationFile[]>,
22
24
  },
23
25
 
24
26
  alias: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/supabase",
3
3
  "type": "module",
4
- "version": "0.2.2",
4
+ "version": "0.4.0",
5
5
  "trustedDependencies": [
6
6
  "@parcel/watcher",
7
7
  "@plutocms/pluto",
@@ -36,6 +36,7 @@
36
36
  "supabase-types": "bun ./scripts/supabase-typegen.ts"
37
37
  },
38
38
  "dependencies": {
39
+ "@nuxt/eslint": "^1.16.0",
39
40
  "@nuxt/ui": "^4.9.0",
40
41
  "@nuxtjs/supabase": "^2.0.9",
41
42
  "@plutocms/pluto": "^0.3.2",
@@ -46,7 +47,6 @@
46
47
  },
47
48
  "devDependencies": {
48
49
  "@antfu/eslint-config": "^6.2.0",
49
- "@nuxt/eslint": "^1.16.0",
50
50
  "@types/bun": "^1.3.14",
51
51
  "@vueuse/nuxt": "^14.3.0",
52
52
  "eslint": "^10.6.0",
@@ -0,0 +1,101 @@
1
+ import type { PlutoMigrationFile } from '../../../shared/types/migrations'
2
+ import type { MigrationFileResult } from '../../utils/migrations'
3
+ import { requireAdmin } from '../../utils/admin-guard'
4
+ import { persistDatabaseUrl } from '../../utils/env-file'
5
+ import { resolveConnectionString, runPendingMigrations } from '../../utils/migrations'
6
+ import { scrubConnectionString } from '../../utils/scrub-connection-string'
7
+ import { regenerateSupabaseTypes } from '../../utils/typegen'
8
+
9
+ interface Payload {
10
+ connectionString?: string
11
+ }
12
+
13
+ export default defineEventHandler(async (event) => {
14
+ await requireAdmin(event)
15
+
16
+ const body = await readBody<Payload | undefined>(event)
17
+
18
+ // Connection string precedence: an already-configured DATABASE_URL always
19
+ // wins, and a body-supplied string is ignored completely in that case.
20
+ // This is the whole security model for this endpoint — without it, any
21
+ // admin could point the server at an arbitrary database host. See
22
+ // resolveConnectionString for the full rule.
23
+ const { connStr, usingBody: usingBodyConnectionString } =
24
+ resolveConnectionString(body?.connectionString)
25
+
26
+ if (!connStr) {
27
+ return {
28
+ success: false as const,
29
+ needsConnectionString: true,
30
+ }
31
+ }
32
+
33
+ const config = useRuntimeConfig()
34
+ // Nuxt's schema inference narrows `plutoLayerMigrations` to whatever
35
+ // layer keys and file shapes it happened to observe at build time (see
36
+ // the `RuntimeConfig` augmentation in shared/types/runtime-config.d.ts),
37
+ // so the read is cast back to the intended general shape.
38
+ const layers = (config.plutoLayerMigrations ?? {}) as unknown as Record<
39
+ string,
40
+ PlutoMigrationFile[]
41
+ >
42
+
43
+ try {
44
+ const layerResults = await runPendingMigrations({
45
+ connectionString: connStr,
46
+ layers,
47
+ })
48
+
49
+ const results: MigrationFileResult[] = layerResults.flatMap(
50
+ (layer) => layer.results
51
+ )
52
+
53
+ const anyApplied = results.some((result) => result.status === 'applied')
54
+
55
+ // Deliberate runtime mutation: a valid connection string just proved
56
+ // itself against the database, so make it available to
57
+ // getConnectionString() for the rest of this process life. No
58
+ // server restart is needed for later requests to pick it up.
59
+ if (usingBodyConnectionString) {
60
+ process.env.DATABASE_URL = connStr
61
+ }
62
+
63
+ // Nothing to persist when DATABASE_URL was already configured.
64
+ // Otherwise persist the now-proven body-supplied string.
65
+ let persisted = !usingBodyConnectionString
66
+ let message: string | undefined
67
+
68
+ if (usingBodyConnectionString) {
69
+ persisted = await persistDatabaseUrl(connStr)
70
+
71
+ if (!persisted) {
72
+ message =
73
+ 'Migrations were applied, but the connection string could not be ' +
74
+ 'saved to .env. Add DATABASE_URL to .env by hand.'
75
+ }
76
+ }
77
+
78
+ if (import.meta.dev && anyApplied && config.plutoRootDir) {
79
+ await regenerateSupabaseTypes(config.plutoRootDir, connStr)
80
+ }
81
+
82
+ return {
83
+ success: true as const,
84
+ results,
85
+ persisted,
86
+ message,
87
+ }
88
+ } catch (error: any) {
89
+ const errorMessage = scrubConnectionString(
90
+ error?.message ?? 'Unknown migration error.',
91
+ connStr
92
+ )
93
+
94
+ console.error('Error applying migrations:', errorMessage)
95
+
96
+ return {
97
+ success: false as const,
98
+ error: errorMessage,
99
+ }
100
+ }
101
+ })