Files
Moheeb Zara 7b8d2ae167 feat(tenant): setup CLI, CD glue, bundled compose, docs + security fix
Tier 2 productization for single-tenant instances, plus fixes from an
adversarial review pass.

New:
- scripts/setup.mjs: 'pnpm setup' CLI (doctor/provision/create-admin/all),
  idempotent, creates the first admin via the Supabase Admin API (email_confirm),
  flag > env > .env.local precedence, masked interactive prompts, --yes/--force.
- scripts/apply-migrations.sh + deploy/deploy.sh: reproducible, idempotent CD
  glue (previously only on the prod droplet), tracked via public._migrations_applied.
- docker-compose.full.yml + .env.full.example: one-command app + self-hosted
  Supabase bundle (compose config validated).
- docs/SINGLE_TENANT.md (deploy + CLI guide) and docs/CUSTOMIZE.md (the
  documented lib/tenant.ts config pattern for white-labeling); README pointer.
- __tests__/tenant.test.ts: 19 unit tests for resolveTenantConfig + isMarketingPath.

Security (found by review, pre-existing):
- Drop the stale handle_space_signup() auth trigger, which derived member role
  and space from client-controlled raw_user_meta_data and could self-insert an
  admin. Membership is created only by the vetted server actions.
  Migration scripts/056_drop_stale_signup_trigger.sql + folded into schema.sql.

Fixes:
- setup.mjs masked-password prompt hung (callback passed to readline/promises
  question); use the awaited form.
- notifications-enqueue.test: base-URL default is now localhost (platform-domain
  fallback removed), not hackerspace.sh.
- docs accuracy (logo/BrandMark, apply-migrations does not restart PostgREST, --yes).

Gates: tsc 0, lint 0 errors, 664 unit tests, build ok.
2026-08-05 13:02:18 -07:00

104 lines
3.8 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# apply-migrations.sh — idempotently apply pending numbered SQL migrations.
#
# For every scripts/0NN_*.sql (in sorted order) this checks public._migrations_applied
# for that filename and, if it is absent, applies the file and records the filename.
# Each file is applied inside a single transaction together with its tracking INSERT,
# so a failure rolls the whole thing back and the file is NOT recorded as applied.
# This makes the script safe to run repeatedly: already-applied files are skipped and
# a re-run with no pending files is a no-op.
#
# How psql is invoked is parameterized via the environment:
# - DATABASE_URL set -> psql "$DATABASE_URL"
# - SUPABASE_DB_CONTAINER set -> docker compose exec -T <container> psql -U postgres -d postgres
# (optionally scoped to a compose file via SUPABASE_DIR/docker-compose.yml)
#
# Exactly one of those must be set. DATABASE_URL wins if both are present.
set -euo pipefail
# Resolve the directory this script lives in; the migrations sit alongside it.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ---------------------------------------------------------------------------
# Decide how to reach psql.
# ---------------------------------------------------------------------------
if [[ -n "${DATABASE_URL:-}" ]]; then
PSQL_MODE="url"
elif [[ -n "${SUPABASE_DB_CONTAINER:-}" ]]; then
PSQL_MODE="docker"
else
echo "error: set DATABASE_URL or SUPABASE_DB_CONTAINER to tell this script how to reach Postgres" >&2
exit 1
fi
# Optional compose-file scope for the docker path (e.g. /opt/supabase/docker).
_compose_file_args=()
if [[ "$PSQL_MODE" == "docker" && -n "${SUPABASE_DIR:-}" ]]; then
_compose_file_args=(-f "$SUPABASE_DIR/docker-compose.yml")
fi
# run_psql: pipe SQL on stdin into Postgres. Extra args are passed to psql.
run_psql() {
if [[ "$PSQL_MODE" == "url" ]]; then
psql "$DATABASE_URL" "$@"
else
docker compose "${_compose_file_args[@]}" exec -T "$SUPABASE_DB_CONTAINER" \
psql -U postgres -d postgres "$@"
fi
}
# ---------------------------------------------------------------------------
# Make sure the tracking table exists. It is also created by scripts/schema.sql
# on a fresh database; this guard keeps the script self-sufficient and idempotent.
# ---------------------------------------------------------------------------
run_psql -v ON_ERROR_STOP=1 -q <<'SQL'
CREATE TABLE IF NOT EXISTS public._migrations_applied (
filename text PRIMARY KEY,
applied_at timestamptz NOT NULL DEFAULT now()
);
SQL
# already_applied FILENAME -> success (0) if the filename is recorded.
already_applied() {
local fn="$1"
local out
out="$(run_psql -tAqc "SELECT 1 FROM public._migrations_applied WHERE filename = '${fn}' LIMIT 1")"
[[ "$out" == *1* ]]
}
# apply_one FILE FILENAME -> apply the SQL and record it, all in one transaction.
# --single-transaction wraps stdin in BEGIN/COMMIT; ON_ERROR_STOP aborts (and thus
# rolls back) on the first error, so the tracking INSERT never lands for a file that
# did not fully apply.
apply_one() {
local file="$1" fn="$2"
{
cat "$file"
printf "\nINSERT INTO public._migrations_applied (filename) VALUES ('%s') ON CONFLICT (filename) DO NOTHING;\n" "$fn"
} | run_psql --single-transaction -v ON_ERROR_STOP=1 -q
}
# ---------------------------------------------------------------------------
# Walk the numbered migrations in sorted order.
# ---------------------------------------------------------------------------
shopt -s nullglob
mapfile -t migrations < <(printf '%s\n' "$SCRIPT_DIR"/0*.sql | sort)
shopt -u nullglob
applied_count=0
for file in "${migrations[@]}"; do
fn="$(basename "$file")"
if already_applied "$fn"; then
continue
fi
apply_one "$file" "$fn"
echo "applied $fn"
applied_count=$((applied_count + 1))
done
if [[ "$applied_count" -eq 0 ]]; then
echo "up to date"
fi