@plutocms/supabase 0.8.3 → 0.8.4

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.8.4](https://github.com/plutocms/supabase/compare/v0.8.3...v0.8.4) (2026-09-14)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * **security:** validate request bodies with zod ([#69](https://github.com/plutocms/supabase/issues/69)) ([94f179b](https://github.com/plutocms/supabase/commit/94f179bbfdd67a55c18764a5bf6fde0cc6635936))
9
+
3
10
  ## [0.8.3](https://github.com/plutocms/supabase/compare/v0.8.2...v0.8.3) (2026-09-13)
4
11
 
5
12
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@plutocms/supabase",
3
3
  "type": "module",
4
- "version": "0.8.3",
4
+ "version": "0.8.4",
5
5
  "trustedDependencies": [
6
6
  "@parcel/watcher",
7
7
  "@plutocms/pluto",
@@ -42,7 +42,8 @@
42
42
  "@plutocms/pluto": "^0.10.0",
43
43
  "@plutocms/utils": "^0.2.1",
44
44
  "postgres": "^3.4.9",
45
- "supabase": "^2.109.1"
45
+ "supabase": "^2.109.1",
46
+ "zod": "^4.6.4"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@antfu/eslint-config": "^6.2.0",
@@ -1,23 +1,22 @@
1
1
  import { serverSupabaseClient } from '#supabase/server'
2
+ import { z } from 'zod'
3
+ import { parseBody } from '../../utils/validate'
2
4
 
3
- interface Payload {
4
- username: string
5
- display_name: string
6
- email: string
7
- password: string
8
- }
5
+ // Supabase Auth (GoTrue) rejects a password under 6 characters with its own
6
+ // opaque error. Matching that minimum here turns an empty/short password
7
+ // into the same clean 400 shape as every other validation failure, instead
8
+ // of a raw auth-service error reaching the caller.
9
+ const signupSchema = z.object({
10
+ username: z.string().trim().min(3, 'Username must be at least 3 characters.').max(100),
11
+ display_name: z.string().trim().min(1, 'Display name is required.').max(200),
12
+ email: z.string().trim().email('Email must be a valid email address.'),
13
+ password: z.string().min(6, 'Password must be at least 6 characters.'),
14
+ })
9
15
 
10
16
  export default defineEventHandler(async (event) => {
11
17
  const client = await serverSupabaseClient<Database>(event)
12
18
 
13
- const body = await readBody<Payload>(event)
14
-
15
- if (!body.username || body.username.length < 3) {
16
- return {
17
- success: false,
18
- message: 'Username must be at least 3 characters.',
19
- }
20
- }
19
+ const body = await parseBody(event, signupSchema)
21
20
 
22
21
  const { data, error } = await client.auth.signUp({
23
22
  email: body.email,
@@ -1,14 +1,20 @@
1
1
  import type { PlutoMigrationFile } from '../../../shared/types/migrations'
2
2
  import type { MigrationFileResult } from '../../utils/migrations'
3
+ import { z } from 'zod'
3
4
  import { requireAdmin } from '../../utils/admin-guard'
4
5
  import { persistDatabaseUrl } from '../../utils/env-file'
5
6
  import { resolveConnectionString, runPendingMigrations } from '../../utils/migrations'
6
7
  import { scrubConnectionString } from '../../utils/scrub-connection-string'
7
8
  import { regenerateSupabaseTypes } from '../../utils/typegen'
9
+ import { parseBody } from '../../utils/validate'
8
10
 
9
- interface Payload {
10
- connectionString?: string
11
- }
11
+ // The whole body is optional: an already-configured DATABASE_URL means the
12
+ // caller need not (and, per resolveConnectionString, cannot) supply one.
13
+ const runMigrationsSchema = z
14
+ .object({
15
+ connectionString: z.string().trim().min(1, 'Connection string must not be empty.').optional(),
16
+ })
17
+ .optional()
12
18
 
13
19
  export default defineEventHandler(async (event) => {
14
20
  // requireAdmin specifically, not a named capability — see admin-guard.ts
@@ -16,7 +22,7 @@ export default defineEventHandler(async (event) => {
16
22
  // (which defines the capability system) has been applied.
17
23
  await requireAdmin(event)
18
24
 
19
- const body = await readBody<Payload | undefined>(event)
25
+ const body = await parseBody(event, runMigrationsSchema)
20
26
 
21
27
  // Connection string precedence: an already-configured DATABASE_URL always
22
28
  // wins, and a body-supplied string is ignored completely in that case.
@@ -1,5 +1,7 @@
1
1
  import { serverSupabaseClient } from '#supabase/server'
2
+ import { z } from 'zod'
2
3
  import { requireCapability } from '../../utils/capability-guard'
4
+ import { parseBody } from '../../utils/validate'
3
5
 
4
6
  // A key is lowercase segments separated by dots, e.g. 'website_title' or
5
7
  // 'blog.posts_per_page'. Mirrors the settings_setting_key_format check
@@ -10,43 +12,26 @@ import { requireCapability } from '../../utils/capability-guard'
10
12
  const SETTING_KEY_PATTERN = /^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/
11
13
  const MAX_SETTING_VALUE_LENGTH = 10_000
12
14
 
15
+ const settingsUpdateSchema = z.record(
16
+ z
17
+ .string()
18
+ .regex(
19
+ SETTING_KEY_PATTERN,
20
+ 'Setting key must use lowercase letters, digits, underscores, and dot-separated segments.'
21
+ ),
22
+ z.string().max(MAX_SETTING_VALUE_LENGTH, 'Setting value is too long.')
23
+ )
24
+
13
25
  export default defineEventHandler(async (event) => {
14
26
  await requireCapability(event, 'settings:manage')
15
27
 
16
28
  const client = await serverSupabaseClient<Database>(event)
17
- const body = await readBody<Record<string, unknown>>(event)
18
-
19
- if (!body || typeof body !== 'object') {
20
- throw createError({ statusCode: 400, statusMessage: 'No payload sent.' })
21
- }
22
-
23
- const transformed = Object.entries(body).map(([key, value]) => {
24
- if (!SETTING_KEY_PATTERN.test(key)) {
25
- throw createError({
26
- statusCode: 400,
27
- statusMessage: `Invalid setting key: ${key}`,
28
- })
29
- }
30
-
31
- if (typeof value !== 'string') {
32
- throw createError({
33
- statusCode: 400,
34
- statusMessage: `Setting "${key}" must be a string.`,
35
- })
36
- }
37
-
38
- if (value.length > MAX_SETTING_VALUE_LENGTH) {
39
- throw createError({
40
- statusCode: 400,
41
- statusMessage: `Setting "${key}" is too long.`,
42
- })
43
- }
29
+ const body = await parseBody(event, settingsUpdateSchema)
44
30
 
45
- return {
46
- setting_key: key,
47
- setting_value: value,
48
- }
49
- })
31
+ const transformed = Object.entries(body).map(([key, value]) => ({
32
+ setting_key: key,
33
+ setting_value: value,
34
+ }))
50
35
 
51
36
  const { data, error } = await client
52
37
  .from('settings')
@@ -1,12 +1,18 @@
1
1
  import type { PlutoMigrationFile } from '../../../shared/types/migrations'
2
2
  import postgres from 'postgres'
3
+ import { z } from 'zod'
3
4
  import { persistDatabaseUrl } from '../../utils/env-file'
4
5
  import { runPendingMigrations } from '../../utils/migrations'
5
6
  import { scrubConnectionString } from '../../utils/scrub-connection-string'
6
-
7
- interface Payload {
8
- connectionString: string
9
- }
7
+ import { parseBody } from '../../utils/validate'
8
+
9
+ // Only checks that a connection string was actually sent. A Postgres
10
+ // connection string can take many valid forms (postgres:// URL, key=value
11
+ // DSN, and so on), so this route's real security model is the
12
+ // DATABASE_URL precedence check above, not a stricter format check here.
13
+ const setupSchema = z.object({
14
+ connectionString: z.string().trim().min(1, 'Connection string must not be empty.'),
15
+ })
10
16
 
11
17
  export default defineEventHandler(async (event) => {
12
18
  // An already-configured DATABASE_URL means setup has already completed.
@@ -23,7 +29,7 @@ export default defineEventHandler(async (event) => {
23
29
  })
24
30
  }
25
31
 
26
- const body = await readBody<Payload>(event)
32
+ const body = await parseBody(event, setupSchema)
27
33
 
28
34
  const config = useRuntimeConfig()
29
35
  // Nuxt's schema inference narrows `plutoLayerMigrations` to whatever
@@ -0,0 +1,44 @@
1
+ import type { H3Event } from 'h3'
2
+ import type { ZodSchema } from 'zod'
3
+
4
+ /**
5
+ * One field-level validation error.
6
+ *
7
+ * Matches `ContentValidationError` from `@plutocms/pluto`'s
8
+ * `shared/utils/content-validate.ts`, so a zod failure and a content-model
9
+ * failure look the same to a caller.
10
+ */
11
+ export interface ValidationError {
12
+ field: string
13
+ message: string
14
+ }
15
+
16
+ /**
17
+ * Reads a request body and checks it against a zod schema.
18
+ *
19
+ * On success, returns the parsed, typed body. On failure, throws the same
20
+ * 400 shape `@plutocms/pluto`'s generic content routes throw for a content
21
+ * validation failure: `{ statusCode: 400, statusMessage: 'Validation
22
+ * failed.', data: { errors } }`, with `errors` as `{ field, message }[]`.
23
+ * Every route in this workspace should call this helper instead of
24
+ * hand-rolling its own zod error handling, so a malformed request fails
25
+ * the same way everywhere.
26
+ *
27
+ * A nested field path (for example `address.zip`) is joined with `.` to
28
+ * build `field`.
29
+ */
30
+ export async function parseBody<T>(event: H3Event, schema: ZodSchema<T>): Promise<T> {
31
+ const body = await readBody(event)
32
+ const result = schema.safeParse(body)
33
+
34
+ if (!result.success) {
35
+ const errors: ValidationError[] = result.error.issues.map((issue) => ({
36
+ field: issue.path.join('.'),
37
+ message: issue.message,
38
+ }))
39
+
40
+ throw createError({ statusCode: 400, statusMessage: 'Validation failed.', data: { errors } })
41
+ }
42
+
43
+ return result.data
44
+ }