Skip to content

Commit 291dab2

Browse files
committed
feat: add fresh migration replay and platform role scope hardening
1 parent 32df23d commit 291dab2

8 files changed

Lines changed: 366 additions & 5 deletions

File tree

docs/MIGRATION_VALIDATION.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Migration Validation
2+
3+
This project includes a repeatable fresh-database migration replay check.
4+
5+
## Command
6+
7+
```bash
8+
npm run db:validate:fresh
9+
```
10+
11+
## What it does
12+
13+
- Starts a temporary local PostgreSQL instance with `initdb`/`pg_ctl`.
14+
- Creates Supabase compatibility stubs (`auth` schema, `auth.users`, `auth.uid()`, `anon/authenticated/service_role` roles).
15+
- Replays all SQL migrations in `supabase/migrations` in lexical order.
16+
- Fails immediately on migration errors (`ON_ERROR_STOP=1`).
17+
18+
## Latest result
19+
20+
- Date: 2026-02-11
21+
- Result: pass
22+
- Evidence summary:
23+
- All migrations through `20260211000700_platform_role_scope_hardening.sql` applied successfully.
24+
- Fresh replay completed with `20` public tables present.
25+
26+
## Notes
27+
28+
- This check validates migration ordering and compatibility in a clean PostgreSQL environment without requiring Docker.
29+
- Supabase-managed runtime services (Auth/Storage APIs) are represented by minimal SQL stubs for migration execution only.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"preview": "vite preview",
1212
"db:migrate": "npx supabase db push",
1313
"db:custom-migrate": "node apply-migrations.js",
14+
"db:validate:fresh": "bash scripts/validate-migrations-fresh.sh",
1415
"test": "vitest --exclude tests/playwright/**",
1516
"test:ci": "vitest --run --passWithNoTests --exclude tests/playwright/**",
1617
"test:watch": "vitest --watch --exclude tests/playwright/**",
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
4+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5+
MIGRATIONS_DIR="$ROOT_DIR/supabase/migrations"
6+
7+
if ! command -v initdb >/dev/null 2>&1; then
8+
echo "initdb is required to run fresh migration validation." >&2
9+
exit 1
10+
fi
11+
12+
if ! command -v pg_ctl >/dev/null 2>&1; then
13+
echo "pg_ctl is required to run fresh migration validation." >&2
14+
exit 1
15+
fi
16+
17+
if ! command -v psql >/dev/null 2>&1; then
18+
echo "psql is required to run fresh migration validation." >&2
19+
exit 1
20+
fi
21+
22+
PORT="${OC_MIGRATION_DB_PORT:-55434}"
23+
DB_NAME="${OC_MIGRATION_DB_NAME:-opencomments_validation}"
24+
25+
TMPROOT="$(mktemp -d /tmp/opencomments-pg.XXXXXX)"
26+
PGDATA="$TMPROOT/data"
27+
PGLOG="$TMPROOT/postgres.log"
28+
29+
cleanup() {
30+
if [[ -d "$PGDATA" ]]; then
31+
pg_ctl -D "$PGDATA" stop -m fast >/dev/null 2>&1 || true
32+
fi
33+
rm -rf "$TMPROOT"
34+
}
35+
trap cleanup EXIT
36+
37+
initdb -D "$PGDATA" -A trust -U postgres >/dev/null
38+
cat >> "$PGDATA/postgresql.conf" <<CONF
39+
fsync = off
40+
listen_addresses = ''
41+
port = $PORT
42+
unix_socket_directories = '/tmp'
43+
CONF
44+
45+
pg_ctl -D "$PGDATA" -l "$PGLOG" start >/dev/null
46+
createdb -h /tmp -p "$PORT" -U postgres "$DB_NAME"
47+
48+
psql -h /tmp -p "$PORT" -U postgres -d "$DB_NAME" <<'SQL'
49+
DO $$
50+
BEGIN
51+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='anon') THEN
52+
CREATE ROLE anon NOLOGIN;
53+
END IF;
54+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='authenticated') THEN
55+
CREATE ROLE authenticated NOLOGIN;
56+
END IF;
57+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='service_role') THEN
58+
CREATE ROLE service_role NOLOGIN;
59+
END IF;
60+
END $$;
61+
62+
CREATE EXTENSION IF NOT EXISTS pgcrypto;
63+
CREATE SCHEMA IF NOT EXISTS auth;
64+
CREATE TABLE IF NOT EXISTS auth.users (
65+
id uuid PRIMARY KEY,
66+
email text,
67+
raw_user_meta_data jsonb DEFAULT '{}'::jsonb,
68+
created_at timestamptz DEFAULT now()
69+
);
70+
CREATE OR REPLACE FUNCTION auth.uid()
71+
RETURNS uuid
72+
LANGUAGE sql
73+
STABLE
74+
AS $$
75+
SELECT '00000000-0000-0000-0000-000000000000'::uuid;
76+
$$;
77+
SQL
78+
79+
echo "Running fresh migration replay from: $MIGRATIONS_DIR"
80+
while IFS= read -r migration; do
81+
echo "APPLYING $(basename "$migration")"
82+
psql -v ON_ERROR_STOP=1 -h /tmp -p "$PORT" -U postgres -d "$DB_NAME" -f "$migration" >/dev/null
83+
echo "OK $(basename "$migration")"
84+
done < <(ls "$MIGRATIONS_DIR"/*.sql | sort)
85+
86+
TABLE_COUNT=$(psql -h /tmp -p "$PORT" -U postgres -d "$DB_NAME" -t -A -c "SELECT count(*) FROM information_schema.tables WHERE table_schema='public';")
87+
88+
echo "Fresh migration replay complete. Public table count: $TABLE_COUNT"

src/hooks/usePlatformRoles.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ export const usePlatformRoles = () => {
143143
}
144144

145145
const inviteUserToAgency = async (request: InviteUserRequest): Promise<string> => {
146+
if (platformRole === 'super_user' && request.role === 'owner') {
147+
throw new Error('Super users cannot assign owner role')
148+
}
149+
146150
try {
147151
const { data, error } = await supabase.rpc('platform_invite_user_to_agency', {
148152
p_agency_id: request.agency_id,

src/pages/platform/PlatformAdmin.tsx

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useState } from 'react'
1+
import React, { useEffect, useMemo, useState } from 'react'
22
import { Navigate } from 'react-router-dom'
33
import { useAuth } from '../../contexts/AuthContext'
44
import { usePlatformRoles } from '../../hooks/usePlatformRoles'
@@ -54,7 +54,7 @@ const PlatformAdmin = () => {
5454
const [inviteForm, setInviteForm] = useState({
5555
agency_id: '',
5656
email: '',
57-
role: 'owner' as AgencyRole,
57+
role: 'reviewer' as AgencyRole,
5858
full_name: ''
5959
})
6060

@@ -64,6 +64,20 @@ const PlatformAdmin = () => {
6464
full_name: ''
6565
})
6666

67+
const assignableAgencyRoles = useMemo(() => {
68+
return Object.entries(AGENCY_ROLES)
69+
.map(([role, info]) => ({ role: role as AgencyRole, info }))
70+
.filter(({ role }) => !(platformRole === 'super_user' && role === 'owner'))
71+
}, [platformRole])
72+
73+
useEffect(() => {
74+
const defaultRole = assignableAgencyRoles[0]?.role || 'reviewer'
75+
setInviteForm((prev) => ({
76+
...prev,
77+
role: assignableAgencyRoles.some((entry) => entry.role === prev.role) ? prev.role : defaultRole,
78+
}))
79+
}, [assignableAgencyRoles])
80+
6781
// Redirect if not authenticated or no platform role
6882
if (!user || !platformRole) {
6983
return <Navigate to="/login" replace />
@@ -111,13 +125,17 @@ const PlatformAdmin = () => {
111125
setSuccessMessage('')
112126

113127
try {
128+
if (platformRole === 'super_user' && inviteForm.role === 'owner') {
129+
throw new Error('Super users cannot assign owner role')
130+
}
131+
114132
await inviteUserToAgency(inviteForm)
115133

116134
setSuccessMessage(`User "${inviteForm.email}" invited successfully as ${AGENCY_ROLES[inviteForm.role].name}`)
117135
setInviteForm({
118136
agency_id: '',
119137
email: '',
120-
role: 'owner',
138+
role: assignableAgencyRoles[0]?.role || 'reviewer',
121139
full_name: ''
122140
})
123141
} catch (err: any) {
@@ -448,7 +466,7 @@ const PlatformAdmin = () => {
448466
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent"
449467
required
450468
>
451-
{Object.entries(AGENCY_ROLES).map(([role, info]) => (
469+
{assignableAgencyRoles.map(({ role, info }) => (
452470
<option key={role} value={role}>
453471
{info.name} - {info.description}
454472
</option>

0 commit comments

Comments
 (0)