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.
This commit is contained in:
Moheeb Zara
2026-08-05 13:02:18 -07:00
parent 646d2e542c
commit 7b8d2ae167
12 changed files with 1989 additions and 49 deletions
+24
View File
@@ -0,0 +1,24 @@
-- 056_drop_stale_signup_trigger.sql
-- =============================================================================
-- SECURITY: drop the stale, unsafe auth-signup trigger.
--
-- public.handle_space_signup() (AFTER INSERT ON auth.users, SECURITY DEFINER)
-- read the new member's role and target space from raw_user_meta_data, which is
-- fully client-controlled (the browser calls supabase.auth.signUp with the anon
-- key and GoTrue stores options.data verbatim). A crafted signup with
-- data: { role: 'admin', space_id: '<known-space-uuid>' }
-- would self-insert an admin space_members row (status 'unverified', approved
-- true) with no invite, bypassing createSpace/joinSpace and their guards. Once an
-- admin approves the pending member (which flips status to 'current' without
-- resetting role) the attacker becomes a full admin.
--
-- The legitimate signup flow never puts space_id/role in auth metadata:
-- membership is created only by the createSpace / joinSpace server actions in
-- lib/auth-actions.ts. The trigger was therefore stale as well as exploitable.
-- Drop it outright rather than trying to sanitize client metadata.
--
-- Idempotent. Folded into scripts/schema.sql for fresh installs.
-- =============================================================================
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
DROP FUNCTION IF EXISTS public.handle_space_signup();
+103
View File
@@ -0,0 +1,103 @@
#!/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
+14 -47
View File
@@ -634,54 +634,21 @@ CREATE TRIGGER on_space_created
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.create_default_channels();
-- Auth signup hook
-- Called when a new user is created in auth.users.
-- Reads metadata from supabase.auth.signUp({ data: { space_id, role, display_name } })
-- and inserts the corresponding space_members row.
CREATE OR REPLACE FUNCTION public.handle_space_signup()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_space_id uuid;
v_role public.member_role;
v_display_name text;
BEGIN
v_space_id := (NEW.raw_user_meta_data->>'space_id')::uuid;
-- Default to 'member' if role is missing or invalid
v_role := COALESCE(
NULLIF(NEW.raw_user_meta_data->>'role', '')::public.member_role,
'member'
);
-- Prefer display_name, fall back to full_name, then email
v_display_name := COALESCE(
NULLIF(NEW.raw_user_meta_data->>'display_name', ''),
NULLIF(NEW.raw_user_meta_data->>'full_name', ''),
NEW.email
);
-- No space_id means this is not a hackerspace signup flow
IF v_space_id IS NULL THEN
RETURN NEW;
END IF;
INSERT INTO public.space_members
(space_id, user_id, role, status, display_name, email, approved)
VALUES
(v_space_id, NEW.id, v_role, 'unverified', v_display_name, NEW.email, true)
ON CONFLICT (space_id, user_id) DO NOTHING;
RETURN NEW;
END;
$$;
-- Auth signup hook — REMOVED (was a privilege-escalation vector). See
-- scripts/056_drop_stale_signup_trigger.sql for the incremental drop on
-- existing databases.
--
-- The old handle_space_signup() trigger derived the new member's role and space
-- from auth.users.raw_user_meta_data, which is fully client-controlled: the
-- browser calls supabase.auth.signUp({ options: { data: {...} } }) with the anon
-- key and GoTrue stores that verbatim. A crafted signup with
-- data:{ role:'admin', space_id:'<known-uuid>' } would self-insert an admin
-- space_members row (approved=true), bypassing the createSpace/joinSpace guards
-- entirely. The legitimate signup flow never sets space_id/role in metadata
-- (membership is created by the vetted server actions in lib/auth-actions.ts),
-- so the trigger was stale as well as dangerous. Dropped, not fixed.
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 FUNCTION public.handle_space_signup();
DROP FUNCTION IF EXISTS public.handle_space_signup();
-- Privilege-escalation guard.
-- RLS lets a member update their own space_members row (so they can fix their
+690
View File
@@ -0,0 +1,690 @@
// -----------------------------------------------------------------------------
// scripts/setup.mjs — one-command setup CLI for a single-tenant deployment.
//
// A hackerspace deployer runs this once to provision their one space AND create
// the first admin login. It talks to Supabase with the service role key, so run
// it from a trusted machine only — never expose the service role key.
//
// USAGE
// pnpm setup Provision the space, then create the first admin,
// then print a success summary + login URL.
// pnpm setup doctor Validate env + DB connectivity (no writes).
// pnpm setup provision Create the space only (idempotent).
// pnpm setup create-admin Create the first admin only (idempotent).
// pnpm setup help Print help.
//
// node scripts/setup.mjs create-admin --admin-email [email protected]
// node scripts/setup.mjs provision --space-name "My Space" --space-slug my-space
//
// CONFIG PRECEDENCE (earlier wins):
// explicit CLI flag > process.env > .env.local file
//
// FLAGS
// --space-name, --space-slug, --space-city
// --admin-email, --admin-password, --admin-name
// --yes non-interactive (never prompt; error out on missing required value)
// --force allow provision even if a DIFFERENT space already exists
//
// REQUIRED RUNTIME ENV (see .env.example / .env.local):
// NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,
// SUPABASE_SERVICE_ROLE_KEY, NEXT_PUBLIC_APP_URL
//
// SETUP ENV (used only here, never at runtime):
// SETUP_SPACE_NAME, SETUP_SPACE_SLUG, SETUP_SPACE_CITY,
// SETUP_ADMIN_EMAIL, SETUP_ADMIN_PASSWORD, SETUP_ADMIN_NAME
//
// EXIT CODES: 0 ok · 1 usage/env error · 2 runtime failure
// -----------------------------------------------------------------------------
import { createClient } from '@supabase/supabase-js'
import { randomBytes, randomInt } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createInterface } from 'node:readline/promises'
import { stdin, stdout } from 'node:process'
// --- Exit codes -------------------------------------------------------------
const EXIT_OK = 0
const EXIT_USAGE = 1
const EXIT_RUNTIME = 2
// A usage/env error we can throw and map to exit code 1. Any other thrown error
// is treated as a runtime failure (exit code 2).
class UsageError extends Error {}
// --- Small pretty-print helpers ---------------------------------------------
const c = {
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', cyan: '\x1b[36m',
}
const useColor = stdout.isTTY
const paint = (color, s) => (useColor ? color + s + c.reset : s)
const ok = (s) => paint(c.green, s)
const bad = (s) => paint(c.red, s)
const warn = (s) => paint(c.yellow, s)
const info = (s) => console.log(s)
const pass = (label) => console.log(` ${ok('PASS')} ${label}`)
const fail = (label) => console.log(` ${bad('FAIL')} ${label}`)
// -----------------------------------------------------------------------------
// .env.local parsing. Tiny KEY=VALUE parser: ignore blank lines and #-comments,
// strip surrounding single/double quotes. No new deps.
// -----------------------------------------------------------------------------
function parseEnvFile(path) {
let text
try {
text = readFileSync(path, 'utf8')
} catch {
return {} // No .env.local is fine — env / flags may cover everything.
}
const out = {}
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const eq = line.indexOf('=')
if (eq === -1) continue
const key = line.slice(0, eq).trim()
if (!key) continue
let value = line.slice(eq + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
out[key] = value
}
return out
}
// -----------------------------------------------------------------------------
// Arg parsing. Supports "--flag value" and "--flag=value" and boolean "--flag".
// -----------------------------------------------------------------------------
const BOOL_FLAGS = new Set(['yes', 'force', 'help'])
function parseArgs(argv) {
const flags = {}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (!a.startsWith('--')) continue
const body = a.slice(2)
const eq = body.indexOf('=')
if (eq !== -1) {
flags[body.slice(0, eq)] = body.slice(eq + 1)
continue
}
if (BOOL_FLAGS.has(body)) {
flags[body] = true
continue
}
// "--flag value": consume the next token if it isn't itself a flag.
const next = argv[i + 1]
if (next !== undefined && !next.startsWith('--')) {
flags[body] = next
i++
} else {
flags[body] = true
}
}
return flags
}
// -----------------------------------------------------------------------------
// Config resolution. Precedence: CLI flag > process.env > .env.local.
// process.env is never overridden by the file (mirrors dotenv semantics).
// -----------------------------------------------------------------------------
function buildConfig(flags) {
const fileEnv = parseEnvFile(resolve(process.cwd(), '.env.local'))
// env: process.env wins over the file.
const env = { ...fileEnv }
for (const [k, v] of Object.entries(process.env)) {
if (v !== undefined) env[k] = v
}
// Resolve a value: CLI flag first, then env (process.env + file).
const pick = (flagName, envName) => {
const f = flags[flagName]
if (typeof f === 'string' && f.trim() !== '') return f
const e = env[envName]
if (typeof e === 'string' && e.trim() !== '') return e
return undefined
}
return { env, flags, pick }
}
// -----------------------------------------------------------------------------
// Validation / normalization helpers.
// -----------------------------------------------------------------------------
function slugify(raw) {
return String(raw)
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
function isValidEmail(email) {
// Deliberately conservative: one @, non-empty local + domain with a dot.
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
function generateInviteCode() {
// Mirror lib/auth-actions.ts::generateInviteCode exactly.
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const prefix = 'HSL'
const year = new Date().getFullYear()
const bytes = randomBytes(8)
let code = ''
for (let i = 0; i < 8; i++) code += chars[bytes[i] % chars.length]
return `${prefix}-${year}-${code}`
}
function generatePassword() {
// Strong, readable-ish random password. 24 chars from a wide alphabet using
// rejection-free randomInt so there is no modulo bias.
const chars =
'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*-_'
let out = ''
for (let i = 0; i < 24; i++) out += chars[randomInt(chars.length)]
return out
}
// -----------------------------------------------------------------------------
// Interactive prompting (only when TTY and not --yes).
// -----------------------------------------------------------------------------
async function promptFor(label, { secret = false } = {}) {
const rl = createInterface({ input: stdin, output: stdout })
try {
// node:readline/promises has no built-in masking; for a secret we write the
// label ourselves, then install a masking output writer BEFORE awaiting the
// (empty-query) question so each typed char echoes as '*'. Using the awaited
// promises form is essential: rl.question(query, cb) treats the 2nd arg as
// options (not a callback) in the promises API, so a callback never fires.
if (secret) {
const write = stdout.write.bind(stdout)
stdout.write(`${label}: `)
rl._writeToOutput = (str) => {
if (str.includes('\n') || str.includes('\r')) write(str)
else write('*')
}
const answer = await rl.question('')
stdout.write('\n')
return answer.trim()
}
const answer = await rl.question(`${label}: `)
return answer.trim()
} finally {
rl.close()
}
}
const canPrompt = (flags) => Boolean(stdin.isTTY) && !flags.yes
// Resolve a required value: flag/env, else prompt (interactive), else error.
async function requireValue(cfg, { flagName, envName, label, secret = false, optional = false }) {
const found = cfg.pick(flagName, envName)
if (found !== undefined) return found
if (optional) {
if (canPrompt(cfg.flags)) {
const v = await promptFor(`${label} (optional, blank to skip)`, { secret })
return v === '' ? undefined : v
}
return undefined
}
if (canPrompt(cfg.flags)) {
let v = ''
while (v === '') {
v = await promptFor(label, { secret })
if (v === '') console.log(warn(' This value is required.'))
}
return v
}
throw new UsageError(
`Missing required value: ${label}. Provide --${flagName} or set ${envName} (in env or .env.local), or run interactively without --yes.`,
)
}
// -----------------------------------------------------------------------------
// Supabase client (service role). Validates the 4 required runtime vars.
// -----------------------------------------------------------------------------
const REQUIRED_RUNTIME = [
'NEXT_PUBLIC_SUPABASE_URL',
'NEXT_PUBLIC_SUPABASE_ANON_KEY',
'SUPABASE_SERVICE_ROLE_KEY',
'NEXT_PUBLIC_APP_URL',
]
function missingRuntimeVars(env) {
return REQUIRED_RUNTIME.filter((k) => {
const v = env[k]
return typeof v !== 'string' || v.trim() === ''
})
}
function makeAdminClient(env) {
return createClient(env.NEXT_PUBLIC_SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
auth: { autoRefreshToken: false, persistSession: false },
})
}
// appBaseUrl equivalent: NEXT_PUBLIC_APP_URL, trailing slash stripped.
function appBaseUrl(env) {
const raw = (env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000').trim()
return raw.replace(/\/+$/, '')
}
// -----------------------------------------------------------------------------
// Subcommand: doctor
// -----------------------------------------------------------------------------
async function cmdDoctor(cfg) {
console.log(c.bold + 'Setup doctor' + c.reset)
console.log(paint(c.dim, 'Validating environment and database connectivity...\n'))
let failed = false
// 1. Required runtime vars present.
const missing = missingRuntimeVars(cfg.env)
for (const k of REQUIRED_RUNTIME) {
if (missing.includes(k)) {
fail(`${k} is set`)
failed = true
} else {
pass(`${k} is set`)
}
}
if (missing.length > 0) {
console.log('\n' + bad('Cannot connect: required variables are missing (see above).'))
console.log('Set them in your environment or .env.local, then re-run `pnpm setup doctor`.')
return EXIT_USAGE
}
// 2. Connect + verify the spaces table exists via a trivial select.
const admin = makeAdminClient(cfg.env)
const { error } = await admin.from('spaces').select('id').limit(1)
if (error) {
fail('Connect with service role and query the "spaces" table')
console.log(paint(c.dim, ` ${error.message}`))
// 42P01 = undefined_table.
if (error.code === '42P01' || /relation .* does not exist/i.test(error.message)) {
console.log(
'\n' + warn('The "spaces" table does not exist. Run the schema first:') +
'\n psql "$DATABASE_URL" -f scripts/schema.sql (then apply scripts/0NN_*.sql)',
)
}
failed = true
} else {
pass('Connect with service role and query the "spaces" table')
}
console.log('')
if (failed) {
console.log(bad('doctor: FAIL'))
return EXIT_RUNTIME
}
console.log(ok('doctor: PASS') + ' — environment looks good.')
return EXIT_OK
}
// -----------------------------------------------------------------------------
// Subcommand: provision (create the space). Idempotent.
// -----------------------------------------------------------------------------
async function cmdProvision(cfg) {
const missing = missingRuntimeVars(cfg.env)
if (missing.length > 0) {
throw new UsageError(`Missing required runtime env: ${missing.join(', ')}. Run \`pnpm setup doctor\`.`)
}
const admin = makeAdminClient(cfg.env)
const rawName = await requireValue(cfg, {
flagName: 'space-name', envName: 'SETUP_SPACE_NAME', label: 'Space name',
})
const rawSlug = await requireValue(cfg, {
flagName: 'space-slug', envName: 'SETUP_SPACE_SLUG', label: 'Space slug (letters, numbers, hyphens)',
})
const rawCity = await requireValue(cfg, {
flagName: 'space-city', envName: 'SETUP_SPACE_CITY', label: 'Space city', optional: true,
})
const name = rawName.trim()
const slug = slugify(rawSlug)
const city = rawCity && rawCity.trim() ? rawCity.trim() : null
if (!name) throw new UsageError('Space name cannot be empty.')
if (!slug) throw new UsageError(`Space slug "${rawSlug}" slugifies to empty. Use letters, numbers, or hyphens.`)
// Idempotency: if a space with this slug already exists, reuse it.
const { data: existing, error: exErr } = await admin
.from('spaces')
.select('id, name, slug, invite_code')
.eq('slug', slug)
.maybeSingle()
if (exErr) throw new Error(`Failed to look up existing space: ${exErr.message}`)
if (existing) {
console.log(ok('Space already exists') + ' (idempotent, nothing to do):')
console.log(` id: ${existing.id}`)
console.log(` name: ${existing.name}`)
console.log(` slug: ${existing.slug}`)
console.log(` invite_code: ${existing.invite_code}`)
return { id: existing.id, slug: existing.slug, invite_code: existing.invite_code, created: false }
}
// Safety: refuse to add a SECOND space to a single-tenant DB unless --force.
const { data: others, error: othersErr } = await admin
.from('spaces')
.select('id, name, slug')
.limit(5)
if (othersErr) throw new Error(`Failed to check existing spaces: ${othersErr.message}`)
if (others && others.length > 0 && !cfg.flags.force) {
console.log(bad('Refusing to create a second space.'))
console.log('This database already contains a different space:')
for (const s of others) console.log(` - ${s.name} (${s.slug})`)
console.log(
'\nA single-tenant deployment hosts exactly one space. If you really intend to\n' +
'add another, re-run with --force. Otherwise check NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG.',
)
throw new UsageError('Provision aborted: a different space already exists (use --force to override).')
}
const invite_code = generateInviteCode()
const { data: space, error: insErr } = await admin
.from('spaces')
.insert({ name, slug, city, invite_code })
.select('id, slug, invite_code')
.single()
if (insErr || !space) {
if (insErr?.code === '23505') {
throw new Error(`A space with slug "${slug}" already exists (race). Re-run to reuse it.`)
}
throw new Error(`Failed to create space: ${insErr?.message ?? 'unknown error'}`)
}
console.log(ok('Created space:'))
console.log(` id: ${space.id}`)
console.log(` name: ${name}`)
console.log(` slug: ${space.slug}`)
console.log(` invite_code: ${space.invite_code}`)
console.log(paint(c.dim, ' (default channels, areas, and tiers were created automatically.)'))
return { id: space.id, slug: space.slug, invite_code: space.invite_code, created: true }
}
// -----------------------------------------------------------------------------
// Resolve the target space for create-admin: by --space-slug/SETUP_SPACE_SLUG,
// else the sole space in the DB.
// -----------------------------------------------------------------------------
async function resolveTargetSpace(cfg, admin) {
const slugRaw = cfg.pick('space-slug', 'SETUP_SPACE_SLUG')
if (slugRaw && slugRaw.trim()) {
const slug = slugify(slugRaw)
const { data, error } = await admin
.from('spaces')
.select('id, name, slug')
.eq('slug', slug)
.maybeSingle()
if (error) throw new Error(`Failed to look up space "${slug}": ${error.message}`)
if (!data) throw new UsageError(`No space with slug "${slug}". Run \`pnpm setup provision\` first.`)
return data
}
// No slug given: require exactly one space.
const { data: spaces, error } = await admin
.from('spaces')
.select('id, name, slug')
.order('created_at', { ascending: true })
.limit(2)
if (error) throw new Error(`Failed to list spaces: ${error.message}`)
if (!spaces || spaces.length === 0) {
throw new UsageError('No space exists yet. Run `pnpm setup provision` first.')
}
if (spaces.length > 1) {
throw new UsageError('Multiple spaces exist; specify which with --space-slug or SETUP_SPACE_SLUG.')
}
return spaces[0]
}
// Find an existing auth user by email (paginates the admin list). Supabase-js
// has no getUserByEmail, so we scan pages of listUsers.
async function findAuthUserByEmail(admin, email) {
const target = email.toLowerCase()
const perPage = 200
for (let page = 1; page <= 50; page++) {
const { data, error } = await admin.auth.admin.listUsers({ page, perPage })
if (error) throw new Error(`Failed to list auth users: ${error.message}`)
const users = data?.users ?? []
const hit = users.find((u) => (u.email ?? '').toLowerCase() === target)
if (hit) return hit
if (users.length < perPage) break // last page
}
return null
}
// -----------------------------------------------------------------------------
// Subcommand: create-admin. Idempotent.
// -----------------------------------------------------------------------------
async function cmdCreateAdmin(cfg) {
const missing = missingRuntimeVars(cfg.env)
if (missing.length > 0) {
throw new UsageError(`Missing required runtime env: ${missing.join(', ')}. Run \`pnpm setup doctor\`.`)
}
const admin = makeAdminClient(cfg.env)
const space = await resolveTargetSpace(cfg, admin)
const rawEmail = await requireValue(cfg, {
flagName: 'admin-email', envName: 'SETUP_ADMIN_EMAIL', label: 'Admin email',
})
const email = rawEmail.trim().toLowerCase()
if (!isValidEmail(email)) throw new UsageError(`"${rawEmail}" is not a valid email address.`)
const nameVal = await requireValue(cfg, {
flagName: 'admin-name', envName: 'SETUP_ADMIN_NAME', label: 'Admin display name', optional: true,
})
const displayName = (nameVal && nameVal.trim()) || email
// Find or create the auth user.
let authUser = await findAuthUserByEmail(admin, email)
let generatedPassword = null
if (authUser) {
console.log(ok('Auth user already exists') + ` (${email}) — reusing it.`)
} else {
// Resolve password: flag/env, else generate (and print once).
let password = cfg.pick('admin-password', 'SETUP_ADMIN_PASSWORD')
if (!password || password.trim() === '') {
if (canPrompt(cfg.flags)) {
const typed = await promptFor('Admin password (blank to auto-generate a strong one)', { secret: true })
password = typed === '' ? null : typed
}
}
if (!password) {
password = generatePassword()
generatedPassword = password
}
const { data: created, error: createErr } = await admin.auth.admin.createUser({
email,
password,
email_confirm: true,
user_metadata: { full_name: displayName },
})
if (createErr || !created?.user) {
throw new Error(`Failed to create auth user: ${createErr?.message ?? 'unknown error'}`)
}
authUser = created.user
console.log(ok('Created auth user:') + ` ${email}`)
}
// Find or create the space_members admin row.
const { data: existingMember, error: memLookupErr } = await admin
.from('space_members')
.select('id, role, status')
.eq('space_id', space.id)
.eq('user_id', authUser.id)
.maybeSingle()
if (memLookupErr) throw new Error(`Failed to look up membership: ${memLookupErr.message}`)
if (existingMember) {
console.log(ok('Admin membership already exists') + ` (role: ${existingMember.role}, status: ${existingMember.status}) — nothing to do.`)
} else {
const { error: memErr } = await admin.from('space_members').insert({
space_id: space.id,
user_id: authUser.id,
display_name: displayName,
email,
role: 'admin',
tier: 'plus',
status: 'current',
approved: true,
onboarding_completed_at: new Date().toISOString(),
})
if (memErr) {
if (memErr.code === '23505') {
console.log(warn('Membership already exists (unique constraint) — treating as done.'))
} else {
throw new Error(`Failed to create admin membership: ${memErr.message}`)
}
} else {
console.log(ok('Created admin membership') + ` in "${space.name}" (${space.slug}).`)
}
}
if (generatedPassword) {
console.log('')
console.log(warn('A password was generated for this admin. Save it now — it is shown only once:'))
console.log(' ' + c.bold + generatedPassword + c.reset)
}
return { email, spaceId: space.id, spaceSlug: space.slug, generatedPassword }
}
// -----------------------------------------------------------------------------
// Default / setup: provision THEN create-admin, then a success summary.
// -----------------------------------------------------------------------------
async function cmdSetup(cfg) {
console.log(c.bold + 'hackerspace setup' + c.reset)
console.log(paint(c.dim, 'Provisioning your space and first admin login...\n'))
console.log(c.bold + 'Step 1/2 — provision space' + c.reset)
const space = await cmdProvision(cfg)
console.log('\n' + c.bold + 'Step 2/2 — create first admin' + c.reset)
const adminResult = await cmdCreateAdmin(cfg)
const base = appBaseUrl(cfg.env)
console.log('')
console.log(ok('Setup complete.'))
console.log('')
console.log(c.bold + 'Summary' + c.reset)
console.log(` Space: ${space.slug}`)
console.log(` Space id: ${space.id}`)
console.log(` Invite code: ${space.invite_code}`)
console.log(` Admin email: ${adminResult.email}`)
console.log(` Login URL: ${base}/login`)
console.log('')
console.log(c.bold + 'Next steps' + c.reset)
console.log(` 1. Open ${base}/login and sign in as ${adminResult.email}.`)
if (adminResult.generatedPassword) {
console.log(' 2. Use the generated password printed above (shown only once).')
console.log(' 3. Set NEXT_PUBLIC_SINGLE_TENANT=true and NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG=' + space.slug + ' if running single-tenant.')
console.log(' 4. You may now clear SETUP_ADMIN_PASSWORD from your env.')
} else {
console.log(' 2. Sign in with the password you configured.')
console.log(' 3. Set NEXT_PUBLIC_SINGLE_TENANT=true and NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG=' + space.slug + ' if running single-tenant.')
console.log(' 4. You may now clear SETUP_ADMIN_PASSWORD from your env.')
}
return EXIT_OK
}
// -----------------------------------------------------------------------------
// Help.
// -----------------------------------------------------------------------------
function printHelp() {
console.log(`${c.bold}hackerspace setup CLI${c.reset}
${c.bold}Usage${c.reset}
pnpm setup [command] [flags]
node scripts/setup.mjs [command] [flags]
${c.bold}Commands${c.reset}
setup (default) provision the space, then create the first admin
doctor validate env + DB connectivity (no writes)
provision create the space only (idempotent)
create-admin create the first admin only (idempotent)
help show this help
${c.bold}Flags${c.reset}
--space-name <name> space display name (env SETUP_SPACE_NAME)
--space-slug <slug> url slug, [a-z0-9-] (env SETUP_SPACE_SLUG)
--space-city <city> optional city (env SETUP_SPACE_CITY)
--admin-email <email> first admin email (env SETUP_ADMIN_EMAIL)
--admin-password <pw> first admin password (env SETUP_ADMIN_PASSWORD)
--admin-name <name> first admin display name (env SETUP_ADMIN_NAME)
--yes non-interactive; never prompt
--force allow provision when a different space already exists
${c.bold}Config precedence${c.reset} (earlier wins)
CLI flag > process.env > .env.local
${c.bold}Required runtime env${c.reset}
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY,
SUPABASE_SERVICE_ROLE_KEY, NEXT_PUBLIC_APP_URL
${c.bold}Examples${c.reset}
pnpm setup
pnpm setup doctor
node scripts/setup.mjs create-admin --admin-email [email protected]
Exit codes: 0 ok · 1 usage/env · 2 runtime failure
`)
}
// -----------------------------------------------------------------------------
// Entry point.
// -----------------------------------------------------------------------------
async function main() {
const argv = process.argv.slice(2)
const command = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'setup'
const flags = parseArgs(argv)
if (command === 'help' || command === '--help' || flags.help) {
printHelp()
return EXIT_OK
}
const known = new Set(['setup', 'doctor', 'provision', 'create-admin'])
if (!known.has(command)) {
console.log(bad(`Unknown command: ${command}`))
console.log('')
printHelp()
return EXIT_USAGE
}
const cfg = buildConfig(flags)
switch (command) {
case 'doctor':
return await cmdDoctor(cfg)
case 'provision':
await cmdProvision(cfg)
return EXIT_OK
case 'create-admin':
await cmdCreateAdmin(cfg)
return EXIT_OK
case 'setup':
default:
return await cmdSetup(cfg)
}
}
main()
.then((code) => process.exit(typeof code === 'number' ? code : EXIT_OK))
.catch((err) => {
if (err instanceof UsageError) {
console.error('\n' + bad('Error: ') + err.message)
process.exit(EXIT_USAGE)
}
console.error('\n' + bad('Error: ') + (err?.message ?? String(err)))
process.exit(EXIT_RUNTIME)
})