@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.
- package/CHANGELOG.md +28 -0
- package/FEATURES.md +3 -0
- package/app/components/EnvPersistWarning.vue +82 -0
- package/app/components/migrations/MigrationsBanner.vue +26 -0
- package/app/components/navbar/NavbarAdminProvider.vue +4 -0
- package/app/composables/auth.ts +11 -9
- package/app/composables/migrations.ts +87 -0
- package/app/middleware/setup-check.ts +8 -1
- package/app/pages/admin/migrations.vue +344 -0
- package/app/pages/admin/setup.vue +109 -7
- package/app/pages/admin.vue +13 -0
- package/db/migrations/002_admin_hardening.sql +107 -0
- package/modules/pluto-migrations.ts +167 -32
- package/nuxt.config.ts +3 -1
- package/package.json +2 -2
- package/server/api/migrations/run.post.ts +101 -0
- package/server/api/migrations/status.get.ts +14 -0
- package/server/api/settings/update.post.ts +34 -6
- package/server/api/setup/create.post.ts +82 -54
- package/server/api/users/[id].get.ts +3 -0
- package/server/api/users/index.get.ts +3 -0
- package/server/plugins/migrations.ts +35 -38
- package/server/utils/admin-guard.ts +46 -0
- package/server/utils/env-file.ts +53 -0
- package/server/utils/ledger.ts +124 -0
- package/server/utils/migrations.ts +0 -0
- package/server/utils/pending-migrations.ts +0 -0
- package/server/utils/scrub-connection-string.ts +37 -0
- package/server/utils/sql.ts +86 -11
- package/shared/types/migrations.d.ts +28 -0
- package/shared/types/runtime-config.d.ts +10 -8
- package/shared/types/supabase.ts +7 -12
- /package/{public/schema.sql → db/migrations/001_baseline.sql} +0 -0
|
@@ -0,0 +1,14 @@
|
|
|
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 status = await getMigrationStatus(event)
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
success: true as const,
|
|
11
|
+
...status,
|
|
12
|
+
needsConnectionString: !status.hasConnection && status.pendingFileCount > 0,
|
|
13
|
+
}
|
|
14
|
+
})
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
+
import { requireAdmin } from '../../utils/admin-guard'
|
|
3
|
+
|
|
4
|
+
// Mirrors the TSettings enum in db/migrations/001_baseline.sql. The Postgres enum
|
|
5
|
+
// already rejects an unknown key at the DB level (as a raw constraint
|
|
6
|
+
// error) — this turns that into a clean 400 instead.
|
|
7
|
+
const KNOWN_SETTING_KEYS = [
|
|
8
|
+
'website_title',
|
|
9
|
+
'website_url',
|
|
10
|
+
'website_description',
|
|
11
|
+
] as const
|
|
2
12
|
|
|
3
13
|
export default defineEventHandler(async (event) => {
|
|
14
|
+
await requireAdmin(event)
|
|
15
|
+
|
|
4
16
|
type SettingName =
|
|
5
17
|
Database['public']['Tables']['settings']['Insert']['setting_name']
|
|
6
18
|
type FormBody = Record<SettingName, string>
|
|
@@ -8,16 +20,32 @@ export default defineEventHandler(async (event) => {
|
|
|
8
20
|
const client = await serverSupabaseClient<Database>(event)
|
|
9
21
|
const body = await readBody<FormBody>(event)
|
|
10
22
|
|
|
11
|
-
if (!body) {
|
|
12
|
-
throw createError({ statusMessage: 'No payload sent.' })
|
|
23
|
+
if (!body || typeof body !== 'object') {
|
|
24
|
+
throw createError({ statusCode: 400, statusMessage: 'No payload sent.' })
|
|
13
25
|
}
|
|
14
26
|
|
|
15
|
-
const transformed = [
|
|
16
|
-
|
|
27
|
+
const transformed = Object.entries(body).map(([key, value]) => {
|
|
28
|
+
if (
|
|
29
|
+
!KNOWN_SETTING_KEYS.includes(key as (typeof KNOWN_SETTING_KEYS)[number])
|
|
30
|
+
) {
|
|
31
|
+
throw createError({
|
|
32
|
+
statusCode: 400,
|
|
33
|
+
statusMessage: `Unknown setting: ${key}`,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (typeof value !== 'string') {
|
|
38
|
+
throw createError({
|
|
39
|
+
statusCode: 400,
|
|
40
|
+
statusMessage: `Setting "${key}" must be a string.`,
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return {
|
|
17
45
|
setting_name: key as SettingName,
|
|
18
46
|
setting_value: value,
|
|
19
|
-
}
|
|
20
|
-
|
|
47
|
+
}
|
|
48
|
+
})
|
|
21
49
|
|
|
22
50
|
const { data, error } = await client
|
|
23
51
|
.from('settings')
|
|
@@ -1,22 +1,51 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { resolve } from 'node:path'
|
|
1
|
+
import type { PlutoMigrationFile } from '../../../shared/types/migrations'
|
|
3
2
|
import postgres from 'postgres'
|
|
4
|
-
import {
|
|
3
|
+
import { persistDatabaseUrl } from '../../utils/env-file'
|
|
4
|
+
import { runPendingMigrations } from '../../utils/migrations'
|
|
5
|
+
import { scrubConnectionString } from '../../utils/scrub-connection-string'
|
|
5
6
|
|
|
6
7
|
interface Payload {
|
|
7
|
-
baseUrl: string
|
|
8
8
|
connectionString: string
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export default defineEventHandler(async (event) => {
|
|
12
|
-
|
|
12
|
+
// An already-configured DATABASE_URL means setup has already completed.
|
|
13
|
+
// This route is reachable with no admin session — no admin can exist
|
|
14
|
+
// before setup creates the profiles table — so once real setup has run,
|
|
15
|
+
// this check is the only thing stopping anyone from re-running it against
|
|
16
|
+
// an arbitrary database. Mirrors the same precedence rule in
|
|
17
|
+
// server/api/migrations/run.post.ts, which calls it "the whole security
|
|
18
|
+
// model for this endpoint."
|
|
19
|
+
if (process.env.DATABASE_URL) {
|
|
20
|
+
throw createError({
|
|
21
|
+
statusCode: 403,
|
|
22
|
+
statusMessage: 'Setup has already been completed.',
|
|
23
|
+
})
|
|
24
|
+
}
|
|
13
25
|
|
|
14
|
-
|
|
15
|
-
baseURL: body.baseUrl,
|
|
16
|
-
})
|
|
26
|
+
const body = await readBody<Payload>(event)
|
|
17
27
|
|
|
18
|
-
|
|
19
|
-
|
|
28
|
+
const config = useRuntimeConfig()
|
|
29
|
+
// Nuxt's schema inference narrows `plutoLayerMigrations` to whatever
|
|
30
|
+
// layer keys and file shapes it happened to observe at build time (see
|
|
31
|
+
// the `RuntimeConfig` augmentation in shared/types/runtime-config.d.ts),
|
|
32
|
+
// so the read is cast back to the intended general shape.
|
|
33
|
+
const layers = (config.plutoLayerMigrations ?? {}) as unknown as Record<
|
|
34
|
+
string,
|
|
35
|
+
PlutoMigrationFile[]
|
|
36
|
+
>
|
|
37
|
+
|
|
38
|
+
// The core layer's migrations are discovered and embedded at build time
|
|
39
|
+
// by the pluto-migrations module, exactly like every other layer — see
|
|
40
|
+
// modules/pluto-migrations.ts. Reading it from there instead of fetching
|
|
41
|
+
// it over HTTP means this route needs no `baseUrl` from the caller, and
|
|
42
|
+
// can no longer be made to fetch or execute SQL from an arbitrary origin.
|
|
43
|
+
if (!layers.core?.length) {
|
|
44
|
+
throw createError({
|
|
45
|
+
statusCode: 500,
|
|
46
|
+
statusMessage:
|
|
47
|
+
'Core schema not found. Reinstall @plutocms/supabase and restart the dev server.',
|
|
48
|
+
})
|
|
20
49
|
}
|
|
21
50
|
|
|
22
51
|
const supabaseUrl = process.env.SUPABASE_URL
|
|
@@ -30,63 +59,62 @@ export default defineEventHandler(async (event) => {
|
|
|
30
59
|
|
|
31
60
|
const connectionString = body.connectionString
|
|
32
61
|
|
|
33
|
-
const sql = postgres(connectionString)
|
|
34
|
-
|
|
35
62
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
} catch {
|
|
60
|
-
// .env doesn't exist yet
|
|
61
|
-
}
|
|
62
|
-
|
|
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'
|
|
63
|
+
// core must apply before every other layer — a layer's migrations may
|
|
64
|
+
// reference core objects (public.profiles, public.is_admin()).
|
|
65
|
+
// runPendingMigrations guarantees this ordering itself.
|
|
66
|
+
const layerResults = await runPendingMigrations({
|
|
67
|
+
connectionString,
|
|
68
|
+
layers,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const coreResult = layerResults.find((layer) => layer.layerName === 'core')
|
|
72
|
+
|
|
73
|
+
// Only mark first_setup complete once the core layer's own migrations
|
|
74
|
+
// are known good. A single failing extra layer must not block this —
|
|
75
|
+
// core already succeeded, so the wizard can still hand off to sign-up —
|
|
76
|
+
// but a failed core layer must not flip first_setup, since the base
|
|
77
|
+
// schema (profiles, healthcheck, and so on) may be incomplete.
|
|
78
|
+
if (coreResult && !coreResult.failed) {
|
|
79
|
+
const sql = postgres(connectionString, { max: 1 })
|
|
80
|
+
try {
|
|
81
|
+
await sql.unsafe(
|
|
82
|
+
`UPDATE public.healthcheck SET config_value = 'false' WHERE config_name = 'first_setup'`
|
|
83
|
+
)
|
|
84
|
+
} finally {
|
|
85
|
+
await sql.end()
|
|
72
86
|
}
|
|
73
|
-
envContent += `DATABASE_URL="${connectionString}"\n`
|
|
74
87
|
}
|
|
75
88
|
|
|
76
|
-
|
|
89
|
+
// Deliberate runtime mutation: the connection string just proved
|
|
90
|
+
// itself against the database, so make it available to
|
|
91
|
+
// getConnectionString() for the rest of this process life, with no
|
|
92
|
+
// server restart needed.
|
|
93
|
+
process.env.DATABASE_URL = connectionString
|
|
94
|
+
|
|
95
|
+
// Persist connection string to .env for future layer migrations. This
|
|
96
|
+
// must stay the very last thing this handler does — see "The
|
|
97
|
+
// dev-server restart on first save" in
|
|
98
|
+
// .claude/skills/layer-migrations/SKILL.md.
|
|
99
|
+
const persisted = await persistDatabaseUrl(connectionString)
|
|
77
100
|
|
|
78
101
|
return {
|
|
79
102
|
success: true,
|
|
80
103
|
message: 'Database setup completed successfully.',
|
|
104
|
+
layers: layerResults,
|
|
105
|
+
persisted,
|
|
81
106
|
}
|
|
82
107
|
} catch (error: any) {
|
|
83
|
-
|
|
108
|
+
const message = scrubConnectionString(
|
|
109
|
+
error.message ?? 'Unknown error.',
|
|
110
|
+
connectionString
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
console.error('Error setting up the database:', message)
|
|
84
114
|
|
|
85
115
|
return {
|
|
86
116
|
success: false,
|
|
87
|
-
error:
|
|
117
|
+
error: message,
|
|
88
118
|
}
|
|
89
|
-
} finally {
|
|
90
|
-
sql.end()
|
|
91
119
|
}
|
|
92
120
|
})
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
+
import { requireAdmin } from '../../utils/admin-guard'
|
|
2
3
|
|
|
3
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
+
await requireAdmin(event)
|
|
6
|
+
|
|
4
7
|
const client = await serverSupabaseClient<Database>(event)
|
|
5
8
|
|
|
6
9
|
const id = event.context.params?.id as string
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { serverSupabaseClient } from '#supabase/server'
|
|
2
|
+
import { requireAdmin } from '../../utils/admin-guard'
|
|
2
3
|
|
|
3
4
|
export default defineEventHandler(async (event) => {
|
|
5
|
+
await requireAdmin(event)
|
|
6
|
+
|
|
4
7
|
const client = await serverSupabaseClient<Database>(event)
|
|
5
8
|
|
|
6
9
|
const { data: users, error } = await client.from('profiles').select('*')
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { PlutoMigrationFile } from '../../shared/types/migrations'
|
|
2
|
+
import { runPendingMigrations } from '../utils/migrations'
|
|
2
3
|
import { regenerateSupabaseTypes } from '../utils/typegen'
|
|
3
4
|
|
|
4
5
|
export default defineNitroPlugin(async () => {
|
|
@@ -8,52 +9,48 @@ export default defineNitroPlugin(async () => {
|
|
|
8
9
|
}
|
|
9
10
|
|
|
10
11
|
const config = useRuntimeConfig()
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
// Nuxt's schema inference narrows `plutoLayerMigrations` to whatever
|
|
13
|
+
// layer keys and file shapes it happened to observe at build time (see
|
|
14
|
+
// the `RuntimeConfig` augmentation in shared/types/runtime-config.d.ts),
|
|
15
|
+
// so the read is cast back to the intended general shape.
|
|
16
|
+
const layers = (config.plutoLayerMigrations ?? {}) as unknown as Record<
|
|
17
|
+
string,
|
|
18
|
+
PlutoMigrationFile[]
|
|
19
|
+
>
|
|
20
|
+
|
|
21
|
+
if (Object.keys(layers).length === 0) {
|
|
16
22
|
return
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
let anyApplied = false
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
console.warn(`[migrations] Layer "${layerName}" migrated successfully.`)
|
|
40
|
-
anyApplied = true
|
|
41
|
-
} else {
|
|
42
|
-
console.error(`[migrations] Layer "${layerName}" failed:`, result.error)
|
|
27
|
+
try {
|
|
28
|
+
const layerResults = await runPendingMigrations({
|
|
29
|
+
connectionString: process.env.DATABASE_URL,
|
|
30
|
+
layers,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
for (const layer of layerResults) {
|
|
34
|
+
for (const result of layer.results) {
|
|
35
|
+
const label = `${result.layerName}/${result.migrationName}`
|
|
36
|
+
|
|
37
|
+
if (result.status === 'skipped') {
|
|
38
|
+
console.warn(`[migrations] ${label} already applied, skipped.`)
|
|
39
|
+
} else if (result.status === 'applied') {
|
|
40
|
+
console.warn(`[migrations] ${label} migrated successfully.`)
|
|
41
|
+
anyApplied = true
|
|
42
|
+
} else {
|
|
43
|
+
console.error(`[migrations] ${label} failed:`, result.error)
|
|
44
|
+
}
|
|
43
45
|
}
|
|
44
|
-
} catch (error) {
|
|
45
|
-
console.error(
|
|
46
|
-
`[migrations] Error running migration for "${layerName}":`,
|
|
47
|
-
error
|
|
48
|
-
)
|
|
49
46
|
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error('[migrations] Error running pending migrations:', error)
|
|
50
49
|
}
|
|
51
50
|
|
|
52
|
-
// Regenerate Database types from the live schema whenever a
|
|
53
|
-
// was newly applied, so `Database` stays in sync without a manual
|
|
54
|
-
//
|
|
55
|
-
// recorded as `skipped`); use the `supabase-types` script to force a
|
|
56
|
-
// refresh in that case.
|
|
51
|
+
// Regenerate Database types from the live schema whenever a migration
|
|
52
|
+
// file was newly applied, so `Database` stays in sync without a manual
|
|
53
|
+
// step.
|
|
57
54
|
if (import.meta.dev && anyApplied && config.plutoRootDir) {
|
|
58
55
|
await regenerateSupabaseTypes(config.plutoRootDir)
|
|
59
56
|
}
|
|
@@ -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,124 @@
|
|
|
1
|
+
import type postgres from 'postgres'
|
|
2
|
+
import { splitStatements } from './sql'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Cheap check for whether `public.pluto_migrations` is already in the
|
|
6
|
+
* target shape (has a `migration_name` column). Running this first, and
|
|
7
|
+
* skipping every DDL statement below when it already passes, avoids
|
|
8
|
+
* taking a DDL lock on the table on every server boot once a database has
|
|
9
|
+
* already been upgraded.
|
|
10
|
+
*/
|
|
11
|
+
const PROBE_SQL = `
|
|
12
|
+
select 1
|
|
13
|
+
from information_schema.columns
|
|
14
|
+
where table_schema = 'public'
|
|
15
|
+
and table_name = 'pluto_migrations'
|
|
16
|
+
and column_name = 'migration_name'
|
|
17
|
+
`
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Brings `public.pluto_migrations` from either shape (none yet, or the old
|
|
21
|
+
* one-row-per-layer shape) to the versioned, per-migration shape:
|
|
22
|
+
*
|
|
23
|
+
* ```sql
|
|
24
|
+
* create table if not exists public.pluto_migrations (
|
|
25
|
+
* id bigint generated by default as identity primary key,
|
|
26
|
+
* layer_name text not null,
|
|
27
|
+
* migration_name text not null,
|
|
28
|
+
* checksum text,
|
|
29
|
+
* applied_at timestamptz not null default now(),
|
|
30
|
+
* constraint pluto_migrations_layer_migration_key unique (layer_name, migration_name)
|
|
31
|
+
* );
|
|
32
|
+
* ```
|
|
33
|
+
*
|
|
34
|
+
* The backfill below (`migration_name = '001_baseline.sql'` for a null
|
|
35
|
+
* row) is safe and correct because an old-shape row can only exist if the
|
|
36
|
+
* old engine ran a layer's single monolithic schema file — that file is,
|
|
37
|
+
* by construction, `001_baseline.sql` for every layer (see
|
|
38
|
+
* `db/migrations/001_baseline.sql` for `core`, and the equivalent file in
|
|
39
|
+
* every converted layer).
|
|
40
|
+
*/
|
|
41
|
+
export const ledgerDdl = `
|
|
42
|
+
create table if not exists public.pluto_migrations (
|
|
43
|
+
id bigint generated by default as identity primary key,
|
|
44
|
+
layer_name text not null,
|
|
45
|
+
migration_name text not null,
|
|
46
|
+
checksum text,
|
|
47
|
+
applied_at timestamptz not null default now(),
|
|
48
|
+
constraint pluto_migrations_layer_migration_key unique (layer_name, migration_name)
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
alter table public.pluto_migrations add column if not exists migration_name text;
|
|
52
|
+
|
|
53
|
+
alter table public.pluto_migrations add column if not exists checksum text;
|
|
54
|
+
|
|
55
|
+
update public.pluto_migrations set migration_name = '001_baseline.sql' where migration_name is null;
|
|
56
|
+
|
|
57
|
+
alter table public.pluto_migrations alter column migration_name set not null;
|
|
58
|
+
|
|
59
|
+
alter table public.pluto_migrations drop constraint if exists pluto_migrations_layer_key;
|
|
60
|
+
|
|
61
|
+
do $$
|
|
62
|
+
begin
|
|
63
|
+
if not exists (
|
|
64
|
+
select 1 from pg_constraint
|
|
65
|
+
where conname = 'pluto_migrations_layer_migration_key'
|
|
66
|
+
and conrelid = 'public.pluto_migrations'::regclass
|
|
67
|
+
) then
|
|
68
|
+
alter table public.pluto_migrations
|
|
69
|
+
add constraint pluto_migrations_layer_migration_key unique (layer_name, migration_name);
|
|
70
|
+
end if;
|
|
71
|
+
end
|
|
72
|
+
$$;
|
|
73
|
+
|
|
74
|
+
alter table public.pluto_migrations enable row level security;
|
|
75
|
+
|
|
76
|
+
do $$
|
|
77
|
+
begin
|
|
78
|
+
if not exists (
|
|
79
|
+
select 1 from pg_policies where tablename = 'pluto_migrations' and policyname = 'Enable read access for authenticated users on pluto_migrations'
|
|
80
|
+
) then
|
|
81
|
+
create policy "Enable read access for authenticated users on pluto_migrations"
|
|
82
|
+
on public.pluto_migrations
|
|
83
|
+
for select
|
|
84
|
+
to authenticated, dashboard_user
|
|
85
|
+
using (true);
|
|
86
|
+
end if;
|
|
87
|
+
end
|
|
88
|
+
$$;
|
|
89
|
+
`
|
|
90
|
+
|
|
91
|
+
async function runLedgerSteps(
|
|
92
|
+
tx: postgres.Sql | postgres.TransactionSql
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
const probe = await tx.unsafe(PROBE_SQL)
|
|
95
|
+
|
|
96
|
+
if (probe.length > 0) {
|
|
97
|
+
// Already in the target shape. Skip all DDL below.
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const statement of splitStatements(ledgerDdl)) {
|
|
102
|
+
await tx.unsafe(statement)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Ensures `public.pluto_migrations` is in the versioned, per-migration
|
|
108
|
+
* shape, running every DDL step in one transaction.
|
|
109
|
+
*
|
|
110
|
+
* Accepts either a plain connection or a connection already inside a
|
|
111
|
+
* transaction. A plain connection (it has `.begin`) opens its own
|
|
112
|
+
* transaction here; a connection already inside a transaction runs the
|
|
113
|
+
* same steps directly, since the caller's transaction already wraps them.
|
|
114
|
+
*/
|
|
115
|
+
export async function ensureLedger(
|
|
116
|
+
sql: postgres.Sql | postgres.TransactionSql
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
if (typeof (sql as postgres.Sql).begin === 'function') {
|
|
119
|
+
await (sql as postgres.Sql).begin((tx) => runLedgerSteps(tx))
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
await runLedgerSteps(sql as postgres.TransactionSql)
|
|
124
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
+
}
|