Fix self-host migrations and first admin setup

This commit is contained in:
Poyraz Avsever
2026-06-14 12:16:23 +03:00
parent e677d69bbe
commit 8fa89168c3
9 changed files with 238 additions and 25 deletions
+1 -1
View File
@@ -99,7 +99,7 @@ docker compose up -d --build
Docker Compose intentionally fails fast when required Supabase environment values are missing. Docker Compose intentionally fails fast when required Supabase environment values are missing.
Coolify and Dokploy users should use `docker-compose.full.yml` for the no-external-service setup, or `docker-compose.yml` when connecting to an existing Supabase backend. See `docs/deployment/self-hosting.md` for the deployment checklist. Coolify users should use `docker-compose.full.yml` for the no-external-service setup. Dokploy users should use `docker-compose.dokploy.yml`, which avoids fixed container names and host port bindings. Use `docker-compose.yml` only when connecting to an existing Supabase backend. See `docs/deployment/self-hosting.md` for the deployment checklist.
### Operations ### Operations
+2
View File
@@ -115,7 +115,9 @@ services:
neta-storage: neta-storage:
condition: service_started condition: service_started
environment: environment:
NETA_MIGRATION_RUNNER_VERSION: "2026-06-14.1"
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Dokploy env}@neta-db:5432/postgres DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Dokploy env}@neta-db:5432/postgres
NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2}
volumes: volumes:
- ./supabase:/app/supabase:ro - ./supabase:/app/supabase:ro
- ./scripts:/app/scripts:ro - ./scripts:/app/scripts:ro
+2
View File
@@ -121,7 +121,9 @@ services:
neta-storage: neta-storage:
condition: service_started condition: service_started
environment: environment:
NETA_MIGRATION_RUNNER_VERSION: "2026-06-14.1"
DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@neta-db:5432/postgres DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@neta-db:5432/postgres
NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2}
volumes: volumes:
- ./supabase:/app/supabase:ro - ./supabase:/app/supabase:ro
- ./scripts:/app/scripts:ro - ./scripts:/app/scripts:ro
+1 -1
View File
@@ -37,4 +37,4 @@ Use the migration helper from the repository root:
DATABASE_URL='postgresql://postgres:password@host:5432/postgres' sh ./scripts/apply-migrations.sh DATABASE_URL='postgresql://postgres:password@host:5432/postgres' sh ./scripts/apply-migrations.sh
``` ```
The helper applies `0001` through `0009` in the order listed above. It uses local `psql` when available, otherwise it runs `psql` through Docker. The helper applies missing queries from `0001` through `0009` in the order listed above and records completed migrations in `neta_internal.schema_migrations`. It uses local `psql` when available, otherwise it runs `psql` through Docker. After migrations, it sends `NOTIFY pgrst, 'reload schema'` so PostgREST can see new RPC functions without a manual restart.
+4 -2
View File
@@ -20,7 +20,7 @@ Services:
- `neta-rest`: PostgREST - `neta-rest`: PostgREST
- `neta-storage`: Supabase Storage with local disk backend - `neta-storage`: Supabase Storage with local disk backend
- `neta-supabase-proxy`: single public API entrypoint - `neta-supabase-proxy`: single public API entrypoint
- `neta-migrations`: one-shot Neta database migration runner - `neta-migrations`: Neta database migration runner
Generate production secrets: Generate production secrets:
@@ -92,7 +92,7 @@ Set the generated full-stack env values in Coolify's environment variables. Rout
## Dokploy ## Dokploy
Create a Compose app from this repository. Use `docker-compose.full.yml` for full-stack deployments and paste the generated env values into the environment panel. Create a Compose app from this repository. Use `docker-compose.dokploy.yml` for full-stack deployments and paste the generated env values into the environment panel. This compose file avoids fixed container names and host port bindings so Dokploy can route services itself.
Route the Neta domain to `neta-web` port `3000`. Route the bundled Supabase API domain, if used, to `neta-supabase-proxy` port `8000`. Route the Neta domain to `neta-web` port `3000`. Route the bundled Supabase API domain, if used, to `neta-supabase-proxy` port `8000`.
@@ -127,3 +127,5 @@ NETA_RESTORE_FORCE=1 sh ./scripts/selfhost-restore.sh ./backups/20260101T120000Z
## First Admin ## First Admin
Open `/register` after the stack starts. The first registered user becomes the admin, and public registration is locked after that. Open `/register` after the stack starts. The first registered user becomes the admin, and public registration is locked after that.
Full-stack deployments apply the first-admin registration guard automatically through `neta-migrations`; the installer should not run SQL manually.
+61 -6
View File
@@ -5,6 +5,54 @@ type FirstAdminSetupState = {
errorMessage?: string; errorMessage?: string;
}; };
function isMissingSetupFunctionError(error: {
code?: string;
message?: string;
}) {
return (
error.code === "PGRST202" ||
error.message?.includes("is_first_admin_setup_available")
);
}
async function getSetupStateFromProfiles(): Promise<FirstAdminSetupState | null> {
const supabaseUrl =
process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL;
const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !serviceRoleKey) {
return null;
}
const endpoint = new URL("/rest/v1/profiles", supabaseUrl);
endpoint.searchParams.set("select", "id");
endpoint.searchParams.set("limit", "1");
try {
const response = await fetch(endpoint, {
cache: "no-store",
headers: {
apikey: serviceRoleKey,
authorization: `Bearer ${serviceRoleKey}`,
},
});
if (!response.ok) {
console.error("First admin setup fallback check failed", {
status: response.status,
body: await response.text(),
});
return null;
}
const rows = (await response.json()) as Array<{ id: string }>;
return { available: rows.length === 0 };
} catch (error) {
console.error("First admin setup fallback request failed", error);
return null;
}
}
export async function getFirstAdminSetupState(): Promise<FirstAdminSetupState> { export async function getFirstAdminSetupState(): Promise<FirstAdminSetupState> {
const supabase = await createClient(); const supabase = await createClient();
const { data, error } = await supabase.rpc("is_first_admin_setup_available"); const { data, error } = await supabase.rpc("is_first_admin_setup_available");
@@ -17,15 +65,22 @@ export async function getFirstAdminSetupState(): Promise<FirstAdminSetupState> {
hint: error.hint, hint: error.hint,
}); });
const missingMigration = const fallbackState = await getSetupStateFromProfiles();
error.code === "PGRST202" ||
error.message?.includes("is_first_admin_setup_available"); if (fallbackState) {
if (isMissingSetupFunctionError(error)) {
console.warn(
"First admin setup RPC is not available through PostgREST yet; using service-role profile fallback.",
);
}
return fallbackState;
}
return { return {
available: false, available: false,
errorMessage: missingMigration errorMessage:
? "İlk kurulum kontrolü yapılamadı. 0009 kayıt kilidi migration dosyasını uygulamanın bağlı olduğu Supabase projesinde çalıştırdığından emin ol." "İlk kurulum kontrolü yapılamadı. Veritabanı hazırlık servisi henüz tamamlanmamış olabilir; birkaç saniye sonra tekrar deneyin.",
: "İlk kurulum kontrolü yapılamadı. Supabase bağlantısını ve sunucu loglarını kontrol et.",
}; };
} }
+130 -13
View File
@@ -3,6 +3,7 @@
set -eu set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
PSQL_IMAGE="${NETA_PSQL_IMAGE:-postgres:16-alpine}"
if [ -z "${DATABASE_URL:-}" ]; then if [ -z "${DATABASE_URL:-}" ]; then
echo "DATABASE_URL is required." >&2 echo "DATABASE_URL is required." >&2
@@ -11,6 +12,93 @@ if [ -z "${DATABASE_URL:-}" ]; then
exit 1 exit 1
fi fi
run_sql() {
sql="$1"
if command -v psql >/dev/null 2>&1; then
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -Atc "$sql"
elif command -v docker >/dev/null 2>&1; then
docker run --rm -i "$PSQL_IMAGE" \
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -Atc "$sql"
else
echo "Neither psql nor docker is available to run migrations." >&2
exit 1
fi
}
query_scalar() {
run_sql "$1" | tail -n 1 | tr -d '[:space:]'
}
ensure_migration_table() {
run_sql "
create schema if not exists neta_internal;
create table if not exists neta_internal.schema_migrations (
version text primary key,
file_path text not null,
applied_at timestamp with time zone default timezone('utc'::text, now()) not null
);
revoke all on schema neta_internal from public;
revoke all on all tables in schema neta_internal from public;
" >/dev/null
}
is_migration_recorded() {
migration_id="$1"
result="$(query_scalar "select case when exists (select 1 from neta_internal.schema_migrations where version = '$migration_id') then 'yes' else 'no' end;")"
[ "$result" = "yes" ]
}
record_migration() {
migration_id="$1"
file_path="$2"
run_sql "
insert into neta_internal.schema_migrations (version, file_path)
values ('$migration_id', '$file_path')
on conflict (version) do update set
file_path = excluded.file_path,
applied_at = timezone('utc'::text, now());
" >/dev/null
}
existing_objects_cover_migration() {
migration_id="$1"
case "$migration_id" in
0001_schema)
query_scalar "select case when to_regclass('public.profiles') is not null and to_regclass('public.tasks') is not null then 'yes' else 'no' end;"
;;
0002_freelancer_os_core)
query_scalar "select case when to_regclass('public.clients') is not null and to_regclass('public.projects') is not null and to_regclass('public.calendar_events') is not null and to_regclass('public.finance_transactions') is not null and to_regclass('public.daily_logs') is not null and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'tasks' and column_name = 'priority') then 'yes' else 'no' end;"
;;
0003_project_planning_assets)
query_scalar "select case when to_regclass('public.project_planning_sections') is not null and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'projects' and column_name = 'cover_image_path') then 'yes' else 'no' end;"
;;
0004_business_os_tables)
query_scalar "select case when to_regclass('public.proposals') is not null and to_regclass('public.contracts') is not null and to_regclass('public.invoices') is not null and to_regclass('public.subscriptions') is not null then 'yes' else 'no' end;"
;;
0005_advanced_crm)
query_scalar "select case when to_regclass('public.client_activities') is not null and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'clients' and column_name = 'pipeline_stage') then 'yes' else 'no' end;"
;;
0006_pgvector_embeddings)
query_scalar "select case when to_regclass('public.document_embeddings') is not null and exists (select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'match_documents') then 'yes' else 'no' end;"
;;
0007_client_portal)
query_scalar "select case when to_regclass('public.project_revisions') is not null and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'profiles' and column_name = 'role') and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'clients' and column_name = 'client_auth_id') then 'yes' else 'no' end;"
;;
0008_project_progress_quota)
query_scalar "select case when exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'projects' and column_name = 'progress_type') and exists (select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'update_project_progress_on_task_change') then 'yes' else 'no' end;"
;;
0009_first_admin_registration_lock)
echo "no"
;;
*)
echo "no"
;;
esac
}
run_sql_file() { run_sql_file() {
file_path="$1" file_path="$1"
absolute_path="$ROOT_DIR/$file_path" absolute_path="$ROOT_DIR/$file_path"
@@ -25,7 +113,7 @@ run_sql_file() {
if command -v psql >/dev/null 2>&1; then if command -v psql >/dev/null 2>&1; then
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$absolute_path" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$absolute_path"
elif command -v docker >/dev/null 2>&1; then elif command -v docker >/dev/null 2>&1; then
docker run --rm -i postgres:16-alpine \ docker run --rm -i "$PSQL_IMAGE" \
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f - < "$absolute_path" psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f - < "$absolute_path"
else else
echo "Neither psql nor docker is available to run migrations." >&2 echo "Neither psql nor docker is available to run migrations." >&2
@@ -33,19 +121,48 @@ run_sql_file() {
fi fi
} }
while IFS= read -r sql_file; do run_migration() {
[ -n "$sql_file" ] || continue migration_id="$1"
run_sql_file "$sql_file" file_path="$2"
if is_migration_recorded "$migration_id"; then
echo "Skipping $file_path; migration $migration_id is already recorded."
return
fi
if [ "$(existing_objects_cover_migration "$migration_id")" = "yes" ]; then
echo "Detected existing database objects for $migration_id; recording without replay."
record_migration "$migration_id" "$file_path"
return
fi
run_sql_file "$file_path"
record_migration "$migration_id" "$file_path"
}
reload_postgrest_schema_cache() {
echo "Reloading PostgREST schema cache."
run_sql "notify pgrst, 'reload schema';" >/dev/null
sleep "${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2}"
}
ensure_migration_table
while IFS='|' read -r migration_id sql_file; do
[ -n "$migration_id" ] || continue
run_migration "$migration_id" "$sql_file"
done <<'SQL_FILES' done <<'SQL_FILES'
supabase/schema.sql 0001_schema|supabase/schema.sql
supabase/migrations/0002_add_freelancer_os_core_tables.sql 0002_freelancer_os_core|supabase/migrations/0002_add_freelancer_os_core_tables.sql
supabase/migrations/0003_add_project_planning_assets.sql 0003_project_planning_assets|supabase/migrations/0003_add_project_planning_assets.sql
supabase/migrations/0004_add_business_os_tables.sql 0004_business_os_tables|supabase/migrations/0004_add_business_os_tables.sql
supabase/migrations/0005_add_advanced_crm_tables.sql 0005_advanced_crm|supabase/migrations/0005_add_advanced_crm_tables.sql
supabase/migrations/0006_add_pgvector_and_embeddings.sql 0006_pgvector_embeddings|supabase/migrations/0006_add_pgvector_and_embeddings.sql
supabase/migrations/0007_add_client_portal_tables.sql 0007_client_portal|supabase/migrations/0007_add_client_portal_tables.sql
supabase/migrations/0008_add_project_progress_and_quota.sql 0008_project_progress_quota|supabase/migrations/0008_add_project_progress_and_quota.sql
supabase/migrations/0009_lock_registration_after_first_admin.sql 0009_first_admin_registration_lock|supabase/migrations/0009_lock_registration_after_first_admin.sql
SQL_FILES SQL_FILES
reload_postgrest_schema_cache
echo "All Neta migrations were applied." echo "All Neta migrations were applied."
@@ -37,3 +37,11 @@ begin
return new; return new;
end; end;
$$; $$;
drop trigger if exists on_auth_user_created on auth.users;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
notify pgrst, 'reload schema';
+29 -2
View File
@@ -175,17 +175,44 @@ drop policy if exists "Users can update their own profile." on public.profiles;
create policy "Users can update their own profile." on public.profiles create policy "Users can update their own profile." on public.profiles
for update using (auth.uid() = id); for update using (auth.uid() = id);
-- First admin setup guard for self-hosted installations
create or replace function public.is_first_admin_setup_available()
returns boolean
language sql
security definer
set search_path = public
as $$
select not exists (
select 1
from public.profiles
limit 1
);
$$;
revoke all on function public.is_first_admin_setup_available() from public;
grant execute on function public.is_first_admin_setup_available() to anon;
grant execute on function public.is_first_admin_setup_available() to authenticated;
-- Function to handle new user signup -- Function to handle new user signup
create or replace function public.handle_new_user() create or replace function public.handle_new_user()
returns trigger as $$ returns trigger
language plpgsql
security definer
set search_path = public
as $$
begin begin
if exists (select 1 from public.profiles limit 1)
and coalesce(new.raw_app_meta_data->>'internal_created', 'false') <> 'true' then
raise exception 'Registration is closed. The first admin account already exists.';
end if;
insert into public.profiles (id, first_name, last_name, avatar_url) insert into public.profiles (id, first_name, last_name, avatar_url)
values (new.id, '', '', '') values (new.id, '', '', '')
on conflict (id) do nothing; on conflict (id) do nothing;
return new; return new;
end; end;
$$ language plpgsql security definer; $$;
-- Trigger to automatically create profile on signup -- Trigger to automatically create profile on signup
drop trigger if exists on_auth_user_created on auth.users; drop trigger if exists on_auth_user_created on auth.users;