@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
@@ -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
- ...Object.entries(body).map(([key, value]) => ({
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 { readFile, writeFile } from 'node:fs/promises'
2
- import { resolve } from 'node:path'
1
+ import type { PlutoMigrationFile } from '../../../shared/types/migrations'
3
2
  import postgres from 'postgres'
4
- import { splitStatements } from '../../utils/sql'
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
- const body = await readBody<Payload>(event)
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
- let schema = await $fetch<string | Blob>('/schema.sql', {
15
- baseURL: body.baseUrl,
16
- })
26
+ const body = await readBody<Payload>(event)
17
27
 
18
- if (typeof schema !== 'string') {
19
- schema = await schema.text()
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
- const statements = splitStatements(schema)
37
-
38
- for (const statement of statements) {
39
- await sql.unsafe(statement)
40
- }
41
-
42
- // Mark first_setup as complete
43
- await sql.unsafe(
44
- `UPDATE public.healthcheck SET config_value = 'false' WHERE config_name = 'first_setup'`
45
- )
46
-
47
- // Record the core schema migration
48
- await sql.unsafe(
49
- `INSERT INTO public.pluto_migrations (layer_name)
50
- VALUES ('core')
51
- ON CONFLICT (layer_name) DO NOTHING`
52
- )
53
-
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
- }
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
- await writeFile(envPath, envContent, 'utf-8')
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
- console.error('Error setting up database:', error)
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: error.message,
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 { runLayerMigration } from '../utils/migrations'
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
- const layerSchemas: Record<string, string> = config.plutoLayerSchemas ?? {}
12
-
13
- const layerNames = Object.keys(layerSchemas)
14
-
15
- if (layerNames.length === 0) {
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
- for (const layerName of layerNames) {
22
- const schemaSql = layerSchemas[layerName]
23
-
24
- if (!schemaSql) {
25
- continue
26
- }
27
-
28
- try {
29
- const result = await runLayerMigration({
30
- layerName,
31
- schemaSql,
32
- })
33
-
34
- if (result.skipped) {
35
- console.warn(
36
- `[migrations] Layer "${layerName}" already applied, skipped.`
37
- )
38
- } else if (result.success) {
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 layer schema
53
- // was newly applied, so `Database` stays in sync without a manual step.
54
- // Re-editing an already-applied layer schema is a no-op here (it's
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
@@ -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
+ }