@plutocms/supabase 0.2.2 → 0.3.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.0](https://github.com/plutocms/supabase/compare/v0.2.2...v0.3.0) (2026-09-10)
4
+
5
+
6
+ ### Features
7
+
8
+ * **migrations:** detect and apply pending layer schemas ([b7f0785](https://github.com/plutocms/supabase/commit/b7f0785838f65dcbf9cb22fb3d21307c0819f70b))
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * **setup:** apply layer schemas when the wizard completes ([f29b2aa](https://github.com/plutocms/supabase/commit/f29b2aae4f8b6c5211f157ad349e0566df57c5cc))
14
+
3
15
  ## [0.2.2](https://github.com/plutocms/supabase/compare/v0.2.1...v0.2.2) (2026-09-10)
4
16
 
5
17
 
package/FEATURES.md CHANGED
@@ -5,3 +5,6 @@ with full detail, so this file stays short.
5
5
 
6
6
  See the workspace-level `CLAUDE.md` (one directory up) for the delegation, writing, and git
7
7
  policies shared by every project here.
8
+
9
+ - Layer migrations: apply pending layer database migrations from the admin UI, after initial
10
+ setup. @.claude/skills/layer-migrations/SKILL.md
@@ -0,0 +1,82 @@
1
+ <script setup lang="ts">
2
+ // Shown when a migration (or the setup wizard) applied successfully but
3
+ // could not write DATABASE_URL to .env — see server/utils/env-file.ts for
4
+ // why this can happen (process.cwd() may not be the project root in a
5
+ // production build).
6
+ //
7
+ // `connectionString` is never sent here from the server — it is the value
8
+ // the admin already typed into this page's own form. Showing it back to
9
+ // the same browser that just typed it does not violate the "never send it
10
+ // back" rule that applies to the server's response; it is only saying it
11
+ // out loud so the admin can finish the one manual step themselves.
12
+ const props = defineProps<{
13
+ connectionString: string
14
+ }>()
15
+
16
+ const toast = useToast()
17
+
18
+ const envLine = computed(() => `DATABASE_URL="${props.connectionString}"`)
19
+
20
+ async function copyEnvLine() {
21
+ try {
22
+ await navigator.clipboard.writeText(envLine.value)
23
+
24
+ toast.add({
25
+ title: 'Copied',
26
+ description: 'The DATABASE_URL line is on your clipboard.',
27
+ icon: 'lucide:check-circle',
28
+ color: 'success',
29
+ })
30
+ } catch {
31
+ toast.add({
32
+ title: 'Could not copy',
33
+ description: 'Select and copy the line by hand instead.',
34
+ icon: 'lucide:circle-x',
35
+ color: 'error',
36
+ })
37
+ }
38
+ }
39
+ </script>
40
+
41
+ <template>
42
+ <UAlert
43
+ color="warning"
44
+ variant="outline"
45
+ icon="lucide:triangle-alert"
46
+ title="Connection string not saved automatically"
47
+ >
48
+ <template #description>
49
+ <div class="flex flex-col gap-y-3">
50
+ <p>
51
+ The database change already succeeded. Pluto could not write
52
+ <code>DATABASE_URL</code> to your project's
53
+ <code>.env</code> file, so it will ask again next time unless you
54
+ add it by hand:
55
+ </p>
56
+
57
+ <ol class="list-inside list-decimal">
58
+ <li>Open (or create) <code>.env</code> in your project's root folder.</li>
59
+ <li>Add this line. Replace any existing <code>DATABASE_URL</code> line with it.</li>
60
+ <li>Restart the server.</li>
61
+ </ol>
62
+
63
+ <div class="flex items-center gap-x-2">
64
+ <code
65
+ class="bg-muted grow overflow-x-auto rounded-md px-2 py-1.5 text-xs whitespace-nowrap"
66
+ >
67
+ {{ envLine }}
68
+ </code>
69
+
70
+ <UButton
71
+ icon="lucide:copy"
72
+ variant="soft"
73
+ color="neutral"
74
+ size="sm"
75
+ aria-label="Copy the DATABASE_URL line"
76
+ @click="copyEnvLine"
77
+ />
78
+ </div>
79
+ </div>
80
+ </template>
81
+ </UAlert>
82
+ </template>
@@ -0,0 +1,26 @@
1
+ <script setup lang="ts">
2
+ // Renders nothing when there is no logged-in admin (status reads as null)
3
+ // or when no layer is pending. See `useMigrations` for the 401/403 handling.
4
+ const { status, pendingCount } = useMigrations()
5
+
6
+ const title = computed(() =>
7
+ pendingCount.value === 1
8
+ ? '1 layer needs a database migration'
9
+ : `${pendingCount.value} layers need a database migration`
10
+ )
11
+ </script>
12
+
13
+ <template>
14
+ <UAlert
15
+ v-if="status && pendingCount > 0"
16
+ :title="title"
17
+ color="warning"
18
+ variant="subtle"
19
+ icon="lucide:triangle-alert"
20
+ >
21
+ <template #description>
22
+ <ULink to="/admin/migrations" class="underline"> Go to Migrations </ULink
23
+ >.
24
+ </template>
25
+ </UAlert>
26
+ </template>
@@ -0,0 +1,74 @@
1
+ export interface LayerMigrationResult {
2
+ layerName: string
3
+ status: 'applied' | 'skipped' | 'failed'
4
+ error?: string
5
+ }
6
+
7
+ interface MigrationsStatusResponse {
8
+ success: boolean
9
+ pending: string[]
10
+ applied: string[]
11
+ hasConnection: boolean
12
+ needsConnectionString: boolean
13
+ }
14
+
15
+ interface RunMigrationsResponse {
16
+ success: boolean
17
+ needsConnectionString?: boolean
18
+ results?: LayerMigrationResult[]
19
+ persisted?: boolean
20
+ message?: string
21
+ }
22
+
23
+ /**
24
+ * Reads and applies pending layer migrations, for admin-only pages and the
25
+ * admin-shell banner.
26
+ *
27
+ * `useFetch` keys the request as `pluto-migrations-status`, so every
28
+ * caller in the app shares the same request and state instead of each
29
+ * firing its own.
30
+ *
31
+ * A 401 or 403 from the status endpoint means the visitor is not a
32
+ * logged-in admin. `status` reads as `null` in that case, so a caller
33
+ * should show nothing — an unauthenticated visitor must see no trace of
34
+ * this feature. Only call this composable from admin pages/components.
35
+ */
36
+ export function useMigrations() {
37
+ const {
38
+ data,
39
+ error,
40
+ status: fetchStatus,
41
+ refresh,
42
+ } = useFetch<MigrationsStatusResponse>('/api/migrations/status', {
43
+ key: 'pluto-migrations-status',
44
+ })
45
+
46
+ const status = computed(() => {
47
+ if (error.value) {
48
+ return null
49
+ }
50
+
51
+ return data.value ?? null
52
+ })
53
+
54
+ const pendingCount = computed(() => status.value?.pending.length ?? 0)
55
+
56
+ async function runMigrations(connectionString?: string) {
57
+ const result = await $fetch<RunMigrationsResponse>('/api/migrations/run', {
58
+ method: 'POST',
59
+ body: connectionString ? { connectionString } : undefined,
60
+ })
61
+
62
+ await refresh()
63
+
64
+ return result
65
+ }
66
+
67
+ return {
68
+ status,
69
+ fetchStatus,
70
+ pendingCount,
71
+ refresh,
72
+ runMigrations,
73
+ }
74
+ }
@@ -20,7 +20,14 @@ export default defineNuxtRouteMiddleware(async (to, _from) => {
20
20
  return navigateTo('/admin/setup')
21
21
  }
22
22
  } else {
23
- // Setup already done — block access to setup page
23
+ // Setup already done — block access to setup page.
24
+ //
25
+ // Keep this redirect even after layer migrations move to
26
+ // /admin/migrations. The setup page prompts an unauthenticated
27
+ // visitor for a raw database connection string, and it is safe only
28
+ // because it is unreachable once setup is done. Reopening it for a
29
+ // routine layer addition would let an anonymous visitor submit a
30
+ // connection string to the server.
24
31
  if (to.path === '/admin/setup') {
25
32
  return navigateTo('/admin/login')
26
33
  }
@@ -0,0 +1,320 @@
1
+ <script setup lang="ts">
2
+ useHead({
3
+ title: 'Migrations',
4
+ })
5
+
6
+ const config = useRuntimeConfig()
7
+ const projectId = config.public.supabase.url
8
+ ?.split('https://')[1]
9
+ ?.split('.')[0]
10
+
11
+ const toast = useToast()
12
+
13
+ const { status, fetchStatus, pendingCount, refresh, runMigrations } =
14
+ useMigrations()
15
+
16
+ const isDev = import.meta.dev
17
+
18
+ const connectionForm = ref({
19
+ connectionString: '',
20
+ password: '',
21
+ })
22
+
23
+ const isRunning = ref(false)
24
+ const lastRun = ref<Awaited<ReturnType<typeof runMigrations>> | null>(null)
25
+ // Kept only in this page's own memory, only to show the manual .env step if
26
+ // persisting fails — see EnvPersistWarning.vue. Never sent anywhere new.
27
+ const lastConnectionString = ref('')
28
+
29
+ /**
30
+ * Polls the status endpoint until it responds again, or gives up.
31
+ *
32
+ * Used only to wait out a dev-server restart (see the comment in the
33
+ * `catch` block of `applyMigrations`) — the server is expected to come
34
+ * back within a few seconds, not to be actually down.
35
+ */
36
+ async function waitForServerAndRefresh(): Promise<boolean> {
37
+ const maxAttempts = 15
38
+ const delayMs = 2000
39
+
40
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
41
+ await new Promise((resolve) => setTimeout(resolve, delayMs))
42
+ await refresh()
43
+
44
+ if (status.value) {
45
+ return true
46
+ }
47
+ }
48
+
49
+ return false
50
+ }
51
+
52
+ async function applyMigrations(useForm: boolean) {
53
+ isRunning.value = true
54
+ lastRun.value = null
55
+
56
+ try {
57
+ const connectionString = useForm
58
+ ? connectionForm.value.connectionString.replace(
59
+ '[YOUR-PASSWORD]',
60
+ encodeURIComponent(connectionForm.value.password)
61
+ )
62
+ : undefined
63
+
64
+ if (connectionString) {
65
+ lastConnectionString.value = connectionString
66
+ }
67
+
68
+ const result = await runMigrations(connectionString)
69
+ lastRun.value = result
70
+
71
+ if (result.success === false) {
72
+ toast.add({
73
+ title: 'Could not apply migrations',
74
+ description: result.needsConnectionString
75
+ ? 'A database connection string is required.'
76
+ : undefined,
77
+ icon: 'lucide:circle-x',
78
+ color: 'error',
79
+ })
80
+
81
+ return
82
+ }
83
+
84
+ toast.add({
85
+ title: 'Migrations applied',
86
+ icon: 'lucide:check-circle',
87
+ color: 'success',
88
+ })
89
+ } catch (error) {
90
+ // Saving a first-time connection string writes DATABASE_URL to .env
91
+ // (server/utils/env-file.ts). In dev, Nuxt restarts the whole dev
92
+ // server whenever .env changes, so this request's connection can drop
93
+ // before its response arrives, even though the migration itself
94
+ // already succeeded server-side. A dropped connection has no HTTP
95
+ // status code — a real error response would — so use that to tell the
96
+ // two apart, and only in dev, where the restart is expected at all.
97
+ const hasStatusCode =
98
+ typeof error === 'object' &&
99
+ error !== null &&
100
+ 'statusCode' in error &&
101
+ (error as { statusCode?: unknown }).statusCode !== undefined
102
+
103
+ if (isDev && useForm && !hasStatusCode) {
104
+ toast.add({
105
+ title: 'Dev server restarting',
106
+ description:
107
+ 'Saving a new connection string restarts the dev server. Reconnecting…',
108
+ icon: 'lucide:refresh-cw',
109
+ color: 'info',
110
+ })
111
+
112
+ const recovered = await waitForServerAndRefresh()
113
+
114
+ toast.add(
115
+ recovered
116
+ ? {
117
+ title: 'Reconnected',
118
+ description:
119
+ pendingCount.value === 0
120
+ ? 'The server is back. All migrations were applied.'
121
+ : `The server is back. ${pendingCount.value} layer(s) still pending — check above.`,
122
+ icon: 'lucide:check-circle',
123
+ color: 'success',
124
+ }
125
+ : {
126
+ title: 'Still reconnecting',
127
+ description:
128
+ 'The dev server is taking longer than expected. Reload this page in a moment.',
129
+ icon: 'lucide:triangle-alert',
130
+ color: 'warning',
131
+ }
132
+ )
133
+
134
+ return
135
+ }
136
+
137
+ if (import.meta.dev) {
138
+ console.error('Error applying migrations:', error)
139
+ }
140
+
141
+ toast.add({
142
+ title: 'Error applying migrations',
143
+ description: 'Please check the console for more details.',
144
+ icon: 'lucide:circle-x',
145
+ color: 'error',
146
+ })
147
+ } finally {
148
+ isRunning.value = false
149
+ }
150
+ }
151
+ </script>
152
+
153
+ <template>
154
+ <AdminView>
155
+ <h1 class="text-3xl font-bold lg:text-4xl">Migrations</h1>
156
+
157
+ <div v-if="fetchStatus === 'pending'" class="flex items-center gap-x-2">
158
+ <Icon name="svg-spinners:ring-resize" />
159
+ <span>Loading migration status…</span>
160
+ </div>
161
+
162
+ <template v-else-if="status">
163
+ <UCard>
164
+ <div class="flex flex-col gap-y-6">
165
+ <UAlert
166
+ v-if="pendingCount === 0"
167
+ color="success"
168
+ variant="outline"
169
+ icon="lucide:check-circle"
170
+ title="Everything is up to date"
171
+ description="No layer needs a database migration."
172
+ />
173
+
174
+ <div v-else class="flex flex-col gap-y-2">
175
+ <h2 class="font-semibold">
176
+ Pending layers ({{ status.pending.length }})
177
+ </h2>
178
+ <ul class="list-inside list-disc text-sm">
179
+ <li v-for="layer in status.pending" :key="layer">
180
+ {{ layer }}
181
+ </li>
182
+ </ul>
183
+ </div>
184
+
185
+ <div v-if="status.applied.length" class="flex flex-col gap-y-2">
186
+ <h2 class="font-semibold">
187
+ Applied layers ({{ status.applied.length }})
188
+ </h2>
189
+ <ul class="list-inside list-disc text-sm">
190
+ <li v-for="layer in status.applied" :key="layer">
191
+ {{ layer }}
192
+ </li>
193
+ </ul>
194
+ </div>
195
+
196
+ <template v-if="pendingCount > 0">
197
+ <UForm
198
+ v-if="status.needsConnectionString"
199
+ class="flex flex-col gap-y-4"
200
+ @submit="applyMigrations(true)"
201
+ >
202
+ <UFormField label="Supabase connection string" required>
203
+ <UInput
204
+ v-model="connectionForm.connectionString"
205
+ :disabled="isRunning"
206
+ placeholder="postgresql://"
207
+ required
208
+ />
209
+
210
+ <template #help>
211
+ You can find your connection string
212
+ <ULink
213
+ :to="`https://supabase.com/dashboard/project/${projectId}/database/settings?showConnect=true&connectTab=direct&method=transaction`"
214
+ target="_blank"
215
+ class="underline"
216
+ external
217
+ >
218
+ clicking here </ULink
219
+ >.
220
+ </template>
221
+ </UFormField>
222
+
223
+ <UFormField label="Database password" required>
224
+ <UInput
225
+ v-model="connectionForm.password"
226
+ :disabled="isRunning"
227
+ type="password"
228
+ placeholder="• • • • •"
229
+ required
230
+ />
231
+ </UFormField>
232
+
233
+ <UAlert
234
+ color="secondary"
235
+ icon="lucide:badge-info"
236
+ variant="outline"
237
+ >
238
+ <template #description>
239
+ Your password is created when you create your Supabase
240
+ project. You can change it in the
241
+ <ULink
242
+ to="https://supabase.com/dashboard/project/_/database/settings"
243
+ target="_blank"
244
+ >
245
+ Supabase Database Settings </ULink
246
+ >.
247
+ </template>
248
+ </UAlert>
249
+
250
+ <UAlert
251
+ color="warning"
252
+ variant="outline"
253
+ title="Why do I need to provide a database password?"
254
+ icon="lucide:info"
255
+ description="It is used once to open a direct PostgreSQL connection and run the pending layer schemas. It is then written to .env on the server, so future migrations need no prompt. It is never stored anywhere else, and never sent back to your browser."
256
+ />
257
+
258
+ <UAlert
259
+ v-if="isDev"
260
+ color="info"
261
+ variant="outline"
262
+ title="This restarts the dev server"
263
+ icon="lucide:refresh-cw"
264
+ description="Saving a new 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."
265
+ />
266
+
267
+ <div class="flex">
268
+ <UButton
269
+ :loading="isRunning"
270
+ :disabled="isRunning"
271
+ type="submit"
272
+ icon="lucide:database"
273
+ class="w-full justify-center sm:w-auto"
274
+ >
275
+ Apply pending migrations
276
+ </UButton>
277
+ </div>
278
+ </UForm>
279
+
280
+ <div v-else class="flex">
281
+ <UButton
282
+ :loading="isRunning"
283
+ :disabled="isRunning"
284
+ icon="lucide:database"
285
+ class="w-full justify-center sm:w-auto"
286
+ @click="applyMigrations(false)"
287
+ >
288
+ Apply pending migrations
289
+ </UButton>
290
+ </div>
291
+ </template>
292
+ </div>
293
+ </UCard>
294
+
295
+ <UCard v-if="lastRun">
296
+ <div class="flex flex-col gap-y-4">
297
+ <h2 class="font-semibold">Last run</h2>
298
+
299
+ <EnvPersistWarning
300
+ v-if="lastRun.success && lastRun.persisted === false"
301
+ :connection-string="lastConnectionString"
302
+ />
303
+
304
+ <ul
305
+ v-if="lastRun.results?.length"
306
+ class="flex flex-col gap-y-1 text-sm"
307
+ >
308
+ <li v-for="result in lastRun.results" :key="result.layerName">
309
+ <span class="font-mono">{{ result.layerName }}</span>
310
+ — {{ result.status }}
311
+ <span v-if="result.error" class="text-error">
312
+ : {{ result.error }}</span
313
+ >
314
+ </li>
315
+ </ul>
316
+ </div>
317
+ </UCard>
318
+ </template>
319
+ </AdminView>
320
+ </template>
@@ -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,56 @@ 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
123
  baseUrl: window.location.origin,
75
- connectionString: databaseForm.value.connectionString.replace(
76
- '[YOUR-PASSWORD]',
77
- encodeURIComponent(databaseForm.value.password)
78
- ),
124
+ connectionString,
79
125
  },
80
126
  })
81
127
 
@@ -90,6 +136,11 @@ async function completeDatabaseSetup() {
90
136
  return
91
137
  }
92
138
 
139
+ if (data.persisted === false) {
140
+ envNotPersisted.value = true
141
+ submittedConnectionString.value = connectionString
142
+ }
143
+
93
144
  toast.add({
94
145
  title: 'Database setup complete',
95
146
  description: 'Your database has been set up successfully.',
@@ -99,6 +150,44 @@ async function completeDatabaseSetup() {
99
150
 
100
151
  stepper.value?.next()
101
152
  } catch (error) {
153
+ // Writing DATABASE_URL to .env (the last step of /api/setup/create,
154
+ // after the schema and every layer migration already succeeded)
155
+ // restarts the dev server. The response can be cut off by that
156
+ // restart even though the setup itself worked. A dropped connection
157
+ // has no HTTP status code — a real error response would — so use
158
+ // that, and only in dev, to tell "still finishing" apart from a real
159
+ // failure. See the matching comment in app/pages/admin/migrations.vue.
160
+ const hasStatusCode =
161
+ typeof error === 'object' &&
162
+ error !== null &&
163
+ 'statusCode' in error &&
164
+ (error as { statusCode?: unknown }).statusCode !== undefined
165
+
166
+ if (import.meta.dev && !hasStatusCode) {
167
+ toast.add({
168
+ title: 'Dev server restarting',
169
+ description: 'Setup is finishing. Reconnecting…',
170
+ icon: 'lucide:refresh-cw',
171
+ color: 'info',
172
+ })
173
+
174
+ const recovered = await waitForServerAndAdvance()
175
+
176
+ if (recovered) {
177
+ return
178
+ }
179
+
180
+ toast.add({
181
+ title: 'Still reconnecting',
182
+ description:
183
+ 'The dev server is taking longer than expected. Reload this page in a moment.',
184
+ icon: 'lucide:triangle-alert',
185
+ color: 'warning',
186
+ })
187
+
188
+ return
189
+ }
190
+
102
191
  if (import.meta.dev) {
103
192
  console.error('Error setting up the database:', error)
104
193
  }
@@ -253,6 +342,15 @@ function handleStepChange(step: number) {
253
342
  </div>
254
343
  </template>
255
344
  </UAlert>
345
+
346
+ <UAlert
347
+ v-if="isDev"
348
+ color="info"
349
+ variant="outline"
350
+ title="This restarts the dev server"
351
+ icon="lucide:refresh-cw"
352
+ 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."
353
+ />
256
354
  </UForm>
257
355
  </section>
258
356
 
@@ -269,6 +367,11 @@ function handleStepChange(step: number) {
269
367
  up.
270
368
  </p>
271
369
 
370
+ <EnvPersistWarning
371
+ v-if="envNotPersisted"
372
+ :connection-string="submittedConnectionString"
373
+ />
374
+
272
375
  <p class="text-center">
273
376
  <UButton
274
377
  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>
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.3.0",
5
5
  "trustedDependencies": [
6
6
  "@parcel/watcher",
7
7
  "@plutocms/pluto",
@@ -0,0 +1,119 @@
1
+ import { requireAdmin } from '../../utils/admin-guard'
2
+ import { persistDatabaseUrl } from '../../utils/env-file'
3
+ import { runLayerMigration } from '../../utils/migrations'
4
+ import { getMigrationStatus } from '../../utils/pending-migrations'
5
+ import { scrubConnectionString } from '../../utils/scrub-connection-string'
6
+ import { regenerateSupabaseTypes } from '../../utils/typegen'
7
+
8
+ interface Payload {
9
+ connectionString?: string
10
+ }
11
+
12
+ interface LayerResult {
13
+ layerName: string
14
+ status: 'applied' | 'skipped' | 'failed'
15
+ error?: string
16
+ }
17
+
18
+ export default defineEventHandler(async (event) => {
19
+ await requireAdmin(event)
20
+
21
+ const body = await readBody<Payload | undefined>(event)
22
+
23
+ // Connection string precedence: an already-configured DATABASE_URL always
24
+ // wins, and a body-supplied string is ignored completely in that case.
25
+ // This is the whole security model for this endpoint — without it, any
26
+ // admin could point the server at an arbitrary database host.
27
+ const envConnectionString = process.env.DATABASE_URL
28
+ const bodyConnectionString = body?.connectionString
29
+ const usingBodyConnectionString = !envConnectionString
30
+
31
+ const connStr = envConnectionString || bodyConnectionString
32
+
33
+ if (!connStr) {
34
+ return {
35
+ success: false as const,
36
+ needsConnectionString: true,
37
+ }
38
+ }
39
+
40
+ const config = useRuntimeConfig()
41
+ const layerSchemas: Record<string, string> = config.plutoLayerSchemas ?? {}
42
+
43
+ const { pending } = await getMigrationStatus(event)
44
+
45
+ const results: LayerResult[] = []
46
+ let anyApplied = false
47
+ // Tracks whether we've already committed a body-supplied connection
48
+ // string to process.env for this request (done once, on first success).
49
+ let connStrCommitted = false
50
+
51
+ for (const layerName of pending) {
52
+ const schemaSql = layerSchemas[layerName]
53
+
54
+ if (!schemaSql) {
55
+ continue
56
+ }
57
+
58
+ const result = await runLayerMigration({
59
+ layerName,
60
+ schemaSql,
61
+ connectionString: connStr,
62
+ })
63
+
64
+ if (result.success) {
65
+ results.push({
66
+ layerName,
67
+ status: result.skipped ? 'skipped' : 'applied',
68
+ })
69
+
70
+ if (!result.skipped) {
71
+ anyApplied = true
72
+ }
73
+
74
+ if (usingBodyConnectionString && !connStrCommitted) {
75
+ // Deliberate runtime mutation: a valid connection string just
76
+ // proved itself against the database, so make it available to
77
+ // getConnectionString() for the rest of this process life. No
78
+ // server restart is needed for later requests to pick it up.
79
+ process.env.DATABASE_URL = connStr
80
+ connStrCommitted = true
81
+ }
82
+ } else {
83
+ const message = scrubConnectionString(
84
+ result.error ?? 'Unknown migration error.',
85
+ connStr
86
+ )
87
+
88
+ console.error(`Migration failed [${layerName}]:`, message)
89
+
90
+ results.push({ layerName, status: 'failed', error: message })
91
+ }
92
+ }
93
+
94
+ // Nothing to persist when DATABASE_URL was already configured. Otherwise
95
+ // only persist once a body-supplied string has proven itself valid.
96
+ let persisted = !usingBodyConnectionString
97
+ let message: string | undefined
98
+
99
+ if (usingBodyConnectionString && connStrCommitted) {
100
+ persisted = await persistDatabaseUrl(connStr)
101
+
102
+ if (!persisted) {
103
+ message =
104
+ 'Migrations were applied, but the connection string could not be ' +
105
+ 'saved to .env. Add DATABASE_URL to .env by hand.'
106
+ }
107
+ }
108
+
109
+ if (import.meta.dev && anyApplied && config.plutoRootDir) {
110
+ await regenerateSupabaseTypes(config.plutoRootDir, connStr)
111
+ }
112
+
113
+ return {
114
+ success: true as const,
115
+ results,
116
+ persisted,
117
+ message,
118
+ }
119
+ })
@@ -0,0 +1,16 @@
1
+ import { requireAdmin } from '../../utils/admin-guard'
2
+ import { getMigrationStatus } from '../../utils/pending-migrations'
3
+
4
+ export default defineEventHandler(async (event) => {
5
+ await requireAdmin(event)
6
+
7
+ const { pending, applied, hasConnection } = await getMigrationStatus(event)
8
+
9
+ return {
10
+ success: true as const,
11
+ pending,
12
+ applied,
13
+ hasConnection,
14
+ needsConnectionString: !hasConnection && pending.length > 0,
15
+ }
16
+ })
@@ -1,6 +1,7 @@
1
- import { readFile, writeFile } from 'node:fs/promises'
2
- import { resolve } from 'node:path'
3
1
  import postgres from 'postgres'
2
+ import { persistDatabaseUrl } from '../../utils/env-file'
3
+ import { runLayerMigration } from '../../utils/migrations'
4
+ import { scrubConnectionString } from '../../utils/scrub-connection-string'
4
5
  import { splitStatements } from '../../utils/sql'
5
6
 
6
7
  interface Payload {
@@ -8,6 +9,12 @@ interface Payload {
8
9
  connectionString: string
9
10
  }
10
11
 
12
+ interface LayerResult {
13
+ layerName: string
14
+ status: 'applied' | 'skipped' | 'failed'
15
+ error?: string
16
+ }
17
+
11
18
  export default defineEventHandler(async (event) => {
12
19
  const body = await readBody<Payload>(event)
13
20
 
@@ -51,40 +58,71 @@ export default defineEventHandler(async (event) => {
51
58
  ON CONFLICT (layer_name) DO NOTHING`
52
59
  )
53
60
 
54
- // Persist connection string to .env for future layer migrations
55
- const envPath = resolve(process.cwd(), '.env')
56
- let envContent = ''
57
- try {
58
- envContent = await readFile(envPath, 'utf-8')
59
- } catch {
60
- // .env doesn't exist yet
61
- }
61
+ // Apply every extra layer's schema too. The core schema must run first
62
+ // (a layer schema may depend on a core object), so this loop stays
63
+ // after the core-schema block above. A single failing layer must not
64
+ // fail the whole wizard — the core schema already succeeded and
65
+ // first_setup is already 'false', so this loop reports per-layer
66
+ // failures instead of throwing.
67
+ const config = useRuntimeConfig()
68
+ const layerSchemas: Record<string, string> = config.plutoLayerSchemas ?? {}
69
+
70
+ const layers: LayerResult[] = []
62
71
 
63
- if (envContent.includes('DATABASE_URL=')) {
64
- envContent = envContent.replace(
65
- /^DATABASE_URL=.*$/m,
66
- `DATABASE_URL="${connectionString}"`
67
- )
68
- } else {
69
- // Ensure there's a trailing newline before appending
70
- if (envContent.length > 0 && !envContent.endsWith('\n')) {
71
- envContent += '\n'
72
+ for (const [layerName, schemaSql] of Object.entries(layerSchemas)) {
73
+ if (!schemaSql) {
74
+ continue
75
+ }
76
+
77
+ const result = await runLayerMigration({
78
+ layerName,
79
+ schemaSql,
80
+ connectionString,
81
+ })
82
+
83
+ if (result.success) {
84
+ layers.push({
85
+ layerName,
86
+ status: result.skipped ? 'skipped' : 'applied',
87
+ })
88
+ } else {
89
+ const message = scrubConnectionString(
90
+ result.error ?? 'Unknown migration error.',
91
+ connectionString
92
+ )
93
+
94
+ console.error(`Layer migration failed [${layerName}]:`, message)
95
+
96
+ layers.push({ layerName, status: 'failed', error: message })
72
97
  }
73
- envContent += `DATABASE_URL="${connectionString}"\n`
74
98
  }
75
99
 
76
- await writeFile(envPath, envContent, 'utf-8')
100
+ // Deliberate runtime mutation: the connection string just proved
101
+ // itself against the database, so make it available to
102
+ // getConnectionString() for the rest of this process life, with no
103
+ // server restart needed.
104
+ process.env.DATABASE_URL = connectionString
105
+
106
+ // Persist connection string to .env for future layer migrations
107
+ const persisted = await persistDatabaseUrl(connectionString)
77
108
 
78
109
  return {
79
110
  success: true,
80
111
  message: 'Database setup completed successfully.',
112
+ layers,
113
+ persisted,
81
114
  }
82
115
  } catch (error: any) {
83
- console.error('Error setting up database:', error)
116
+ const message = scrubConnectionString(
117
+ error.message ?? 'Unknown error.',
118
+ connectionString
119
+ )
120
+
121
+ console.error('Error setting up the database:', message)
84
122
 
85
123
  return {
86
124
  success: false,
87
- error: error.message,
125
+ error: message,
88
126
  }
89
127
  } finally {
90
128
  sql.end()
@@ -0,0 +1,46 @@
1
+ import type { H3Event } from 'h3'
2
+ import { serverSupabaseClient, serverSupabaseUser } from '#supabase/server'
3
+
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.
19
+ *
20
+ * 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.
22
+ */
23
+ 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
46
+ }
@@ -0,0 +1,53 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import { resolve } from 'node:path'
3
+
4
+ /**
5
+ * Writes (or updates) `DATABASE_URL` in the project's `.env` file.
6
+ *
7
+ * Reads the current file, replaces an existing `DATABASE_URL` line or
8
+ * appends a new one, then writes it back. Never throws: a failure to read
9
+ * or write is swallowed and reported as `false`, so a caller can still
10
+ * report success for work that is already durably recorded elsewhere (for
11
+ * example, an applied migration row) even when `.env` could not be
12
+ * updated.
13
+ *
14
+ * Caveat: `process.cwd()` may not point at the project root in every
15
+ * deployment (a production build can start from a different working
16
+ * directory). This best-effort write can silently target the wrong file in
17
+ * that case.
18
+ */
19
+ export async function persistDatabaseUrl(
20
+ connectionString: string
21
+ ): Promise<boolean> {
22
+ try {
23
+ const envPath = resolve(process.cwd(), '.env')
24
+ let envContent = ''
25
+
26
+ try {
27
+ envContent = await readFile(envPath, 'utf-8')
28
+ } catch {
29
+ // .env doesn't exist yet.
30
+ }
31
+
32
+ if (envContent.includes('DATABASE_URL=')) {
33
+ envContent = envContent.replace(
34
+ /^DATABASE_URL=.*$/m,
35
+ `DATABASE_URL="${connectionString}"`
36
+ )
37
+ } else {
38
+ // Ensure there's a trailing newline before appending.
39
+ if (envContent.length > 0 && !envContent.endsWith('\n')) {
40
+ envContent += '\n'
41
+ }
42
+ envContent += `DATABASE_URL="${connectionString}"\n`
43
+ }
44
+
45
+ await writeFile(envPath, envContent, 'utf-8')
46
+
47
+ return true
48
+ } catch (error) {
49
+ console.error('Error persisting DATABASE_URL to .env:', error)
50
+
51
+ return false
52
+ }
53
+ }
@@ -0,0 +1,52 @@
1
+ import type { H3Event } from 'h3'
2
+ import { serverSupabaseClient } from '#supabase/server'
3
+
4
+ export interface MigrationStatus {
5
+ discovered: string[]
6
+ applied: string[]
7
+ pending: string[]
8
+ hasConnection: boolean
9
+ }
10
+
11
+ /**
12
+ * Computes which layer schemas are discovered, applied, and pending.
13
+ *
14
+ * This reads `pluto_migrations` through RLS with the caller's own session
15
+ * (the table policy limits reads to `authenticated`). Call this only from a
16
+ * route already gated by `requireAdmin` — an anonymous caller would see zero
17
+ * rows (not an error), which would make every discovered layer look
18
+ * pending and leak layer names to an unauthenticated visitor.
19
+ *
20
+ * Never returns the SQL content of a layer schema, only its name.
21
+ */
22
+ export async function getMigrationStatus(
23
+ event: H3Event
24
+ ): Promise<MigrationStatus> {
25
+ const config = useRuntimeConfig()
26
+ const layerSchemas: Record<string, string> = config.plutoLayerSchemas ?? {}
27
+ const discovered = Object.keys(layerSchemas)
28
+
29
+ const client = await serverSupabaseClient<Database>(event)
30
+
31
+ const { data, error } = await client
32
+ .from('pluto_migrations')
33
+ .select('layer_name')
34
+
35
+ if (error) {
36
+ throw createError({
37
+ statusCode: 500,
38
+ statusMessage: `Error reading migration status: ${error.message}`,
39
+ })
40
+ }
41
+
42
+ const applied = (data ?? []).map((row) => row.layer_name)
43
+ const appliedSet = new Set(applied)
44
+ const pending = discovered.filter((name) => !appliedSet.has(name))
45
+
46
+ return {
47
+ discovered,
48
+ applied,
49
+ pending,
50
+ hasConnection: Boolean(process.env.DATABASE_URL),
51
+ }
52
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Removes a connection string, and its password segment, from an error
3
+ * message before it is logged or returned to a client.
4
+ *
5
+ * A `postgres` error can embed the full connection string (host, user,
6
+ * password) in its message. Call this on every error message that might
7
+ * have touched a connection string, before it is logged or sent in a
8
+ * response.
9
+ */
10
+ export function scrubConnectionString(
11
+ message: string,
12
+ connStr: string
13
+ ): string {
14
+ if (!message || !connStr) {
15
+ return message
16
+ }
17
+
18
+ let scrubbed = message.split(connStr).join('[redacted]')
19
+
20
+ const passwordMatch = connStr.match(/:\/\/[^:@/]+:([^@]+)@/)
21
+ const password = passwordMatch?.[1]
22
+
23
+ if (password) {
24
+ scrubbed = scrubbed.split(password).join('[redacted]')
25
+
26
+ try {
27
+ const decodedPassword = decodeURIComponent(password)
28
+ if (decodedPassword !== password) {
29
+ scrubbed = scrubbed.split(decodedPassword).join('[redacted]')
30
+ }
31
+ } catch {
32
+ // Password wasn't URI-encoded — nothing more to decode.
33
+ }
34
+ }
35
+
36
+ return scrubbed
37
+ }