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
+137
View File
@@ -0,0 +1,137 @@
# =============================================================================
# .env.full.example - config for docker-compose.full.yml (the convenience
# bundle: app + self-hosted Supabase in one command).
#
# cp .env.full.example .env.full
# # fill in the secrets below, then:
# docker compose -f docker-compose.full.yml --env-file .env.full up -d
#
# Every variable docker-compose.full.yml references is documented here. This is
# the fast path. For the hardened, internet-facing deployment (Nginx + TLS,
# backups, Studio behind auth) follow docs/DEPLOY_DO_SELFHOSTED.md; that guide
# is the source of truth for real deployments.
#
# The four critical secrets are POSTGRES_PASSWORD, JWT_SECRET, ANON_KEY, and
# SERVICE_ROLE_KEY. ANON_KEY and SERVICE_ROLE_KEY are JWTs SIGNED WITH the same
# JWT_SECRET you set below: if you change JWT_SECRET you MUST regenerate both.
# Never commit .env.full. Lock it down: chmod 600 .env.full
# =============================================================================
# -----------------------------------------------------------------------------
# Postgres
# -----------------------------------------------------------------------------
# Superuser password for Postgres. Also used by auth/rest/realtime/meta to
# connect. Generate: openssl rand -base64 24
POSTGRES_PASSWORD=replace-with-openssl-rand-base64-24
# Database name. Keep "postgres" unless you have a reason not to.
POSTGRES_DB=postgres
# Host port the Postgres admin port is published on (bound to 127.0.0.1 only,
# for psql / pg_dump from the host). Container always listens on 5432.
POSTGRES_PORT=5432
# -----------------------------------------------------------------------------
# JWT / API keys (the god-mode secrets)
# -----------------------------------------------------------------------------
# Symmetric secret GoTrue/PostgREST/Realtime use to sign and verify JWTs.
# 32+ random bytes. Generate: openssl rand -hex 32
JWT_SECRET=replace-with-openssl-rand-hex-32
# Access-token lifetime in seconds (1 hour). Also used as the JWT expiry hint.
JWT_EXPIRY=3600
# Public "anon" API key. A JWT with role=anon signed by JWT_SECRET above.
# This is the NEXT_PUBLIC_SUPABASE_ANON_KEY the app/browser use. Generate with
# JWT_SECRET exported, on your local machine:
# export JWT_SECRET='the-value-above'
# npx -y -p jsonwebtoken@9 node -e "const jwt=require('jsonwebtoken');const iat=Math.floor(Date.now()/1000);console.log(jwt.sign({role:'anon',iss:'supabase',iat,exp:iat+60*60*24*365*10},process.env.JWT_SECRET))"
ANON_KEY=replace-with-anon-jwt-signed-by-JWT_SECRET
# Service-role API key. A JWT with role=service_role signed by JWT_SECRET.
# Bypasses RLS (god mode) - this is the app's SUPABASE_SERVICE_ROLE_KEY. Same
# command as above with role:'service_role'. Treat like root credentials.
# npx -y -p jsonwebtoken@9 node -e "const jwt=require('jsonwebtoken');const iat=Math.floor(Date.now()/1000);console.log(jwt.sign({role:'service_role',iss:'supabase',iat,exp:iat+60*60*24*365*10},process.env.JWT_SECRET))"
SERVICE_ROLE_KEY=replace-with-service-role-jwt-signed-by-JWT_SECRET
# Realtime needs its own long random base for signing/cookies. Independent of
# JWT_SECRET. Generate: openssl rand -hex 32
SECRET_KEY_BASE=replace-with-openssl-rand-hex-32
# -----------------------------------------------------------------------------
# Public URLs
# -----------------------------------------------------------------------------
# Where GoTrue sends users back after auth flows, and the app's own public URL.
# For a local bundle these are localhost; for a server put your real domains
# here (and terminate TLS in a reverse proxy in front of Kong / the app).
SITE_URL=http://localhost:3000
# Comma-separated extra redirect URLs GoTrue will accept.
ADDITIONAL_REDIRECT_URLS=http://localhost:3000
# The externally reachable base URL of the Supabase API gateway (Kong). GoTrue
# embeds this in email links. Local bundle: http://localhost:8000
API_EXTERNAL_URL=http://localhost:8000
# Same gateway URL, shown by Studio. Usually identical to API_EXTERNAL_URL.
SUPABASE_PUBLIC_URL=http://localhost:8000
# -----------------------------------------------------------------------------
# Kong (API gateway) host ports (bound to 127.0.0.1 by the compose file)
# -----------------------------------------------------------------------------
KONG_HTTP_PORT=8000
KONG_HTTPS_PORT=8443
# -----------------------------------------------------------------------------
# Studio (admin dashboard) (bound to 127.0.0.1 by the compose file)
# -----------------------------------------------------------------------------
# Host port Studio is published on. Keep it off 3000 (the app owns that).
STUDIO_PORT=3001
STUDIO_DEFAULT_ORGANIZATION=hackerspace
STUDIO_DEFAULT_PROJECT=hackerspace
# -----------------------------------------------------------------------------
# PostgREST
# -----------------------------------------------------------------------------
# Schemas PostgREST exposes over /rest/v1. The app only needs public; the rest
# match the Supabase defaults.
PGRST_DB_SCHEMAS=public,storage,graphql_public
# -----------------------------------------------------------------------------
# Auth behavior
# -----------------------------------------------------------------------------
# Allow email/password signup (the app's join flow needs this).
ENABLE_EMAIL_SIGNUP=true
# Auto-confirm new emails WITHOUT sending a verification mail. Handy for local
# dev when you have no SMTP configured; set false in production.
ENABLE_EMAIL_AUTOCONFIRM=true
# -----------------------------------------------------------------------------
# SMTP for auth emails (invites, confirmations, password resets).
# Optional for local dev if ENABLE_EMAIL_AUTOCONFIRM=true. Required in prod.
# Use any provider (Resend, SendGrid, SES, Postmark, Mailgun).
# -----------------------------------------------------------------------------
SMTP_ADMIN_EMAIL=admin@example.com
SMTP_HOST=smtp.resend.com
SMTP_PORT=587
SMTP_USER=resend
SMTP_PASS=replace-with-smtp-api-key
SMTP_SENDER_NAME=hackerspace.sh
# -----------------------------------------------------------------------------
# The app (Next.js) - these feed both build args and runtime env.
# -----------------------------------------------------------------------------
# Browser-reachable Supabase (Kong) URL. Baked into the client bundle at build
# time, so it must be what the BROWSER can reach: http://localhost:8000 locally,
# or https://supabase.yourdomain.com behind a proxy. Keep it equal to
# API_EXTERNAL_URL. (The app's anon key = ANON_KEY above; service key =
# SERVICE_ROLE_KEY above - the compose wires those through automatically.)
NEXT_PUBLIC_SUPABASE_URL=http://localhost:8000
# Public base URL of the app itself (OAuth redirects, emailed links). No
# trailing slash.
NEXT_PUBLIC_APP_URL=http://localhost:3000
# -----------------------------------------------------------------------------
# Single-tenant mode (optional; see docs/SINGLE_TENANT.md).
# Leave blank for the default multi-tenant platform. Set NEXT_PUBLIC_SINGLE_TENANT=true
# to dedicate this instance to exactly one space.
# -----------------------------------------------------------------------------
NEXT_PUBLIC_SINGLE_TENANT=
NEXT_PUBLIC_SITE_NAME=
NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG=
NEXT_PUBLIC_SINGLE_TENANT_OPEN_JOIN=
NEXT_PUBLIC_SHOW_MARKETING=
+4
View File
@@ -78,6 +78,10 @@ Summary:
The deploy script is idempotent and tracked: every numbered migration in `scripts/` is recorded in `public._migrations_applied`. New migrations are applied automatically on push.
### Single-tenant deployments
By default the app is the multi-tenant platform (one deployment hosts many spaces). You can also run it as a private, white-labeled instance for exactly one space: signup becomes "join THE space", the create-a-space flow is disabled, and the hackerspace.sh marketing shell is hidden. Set `NEXT_PUBLIC_SINGLE_TENANT=true`, then run `pnpm setup` once to create the space and the first admin. See [docs/SINGLE_TENANT.md](./docs/SINGLE_TENANT.md) for the end-to-end guide and [docs/CUSTOMIZE.md](./docs/CUSTOMIZE.md) for the branding and configuration pattern.
## Database
`scripts/schema.sql` is the canonical, idempotent, full schema. Run it once on a fresh Supabase project. Subsequent changes are added as numbered migrations (`scripts/021_*.sql`, `022_*.sql`, etc.) and applied incrementally.
+6 -2
View File
@@ -148,11 +148,15 @@ describe('buildManageUrl', () => {
expect(buildManageUrl('example.test')).toBe('https://example.test/me')
})
it('falls back to NEXT_PUBLIC_APP_URL then to the prod default', () => {
it('falls back to NEXT_PUBLIC_APP_URL then to the configured default', () => {
process.env.NEXT_PUBLIC_APP_URL = 'https://my.test'
expect(buildManageUrl(null)).toBe('https://my.test/me')
delete process.env.NEXT_PUBLIC_APP_URL
expect(buildManageUrl(null)).toBe('https://hackerspace.sh/me')
// With no host header and no NEXT_PUBLIC_APP_URL set, buildManageUrl now
// resolves through appBaseUrl(), whose default is localhost (the old
// hardcoded https://hackerspace.sh fallback was removed so a fork/instance
// never leaks the platform domain into its emails).
expect(buildManageUrl(null)).toBe('http://localhost:3000/me')
})
})
+157
View File
@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest'
import { resolveTenantConfig, isMarketingPath } from '@/lib/tenant'
describe('resolveTenantConfig', () => {
it('applies platform defaults when every flag is empty', () => {
expect(resolveTenantConfig({})).toEqual({
singleTenant: false,
siteName: 'hackerspace.sh',
appUrl: 'http://localhost:3000',
spaceSlug: null,
openJoin: false,
allowSpaceCreation: true,
showMarketing: true,
})
})
it('treats singleTenant as true only for the exact string "true"', () => {
expect(resolveTenantConfig({ singleTenant: 'true' }).singleTenant).toBe(true)
expect(resolveTenantConfig({ singleTenant: 'TRUE' }).singleTenant).toBe(false)
expect(resolveTenantConfig({ singleTenant: '1' }).singleTenant).toBe(false)
expect(resolveTenantConfig({ singleTenant: '' }).singleTenant).toBe(false)
})
describe('single-tenant mode', () => {
it('forbids space creation and hides marketing by default', () => {
const cfg = resolveTenantConfig({ singleTenant: 'true' })
expect(cfg.allowSpaceCreation).toBe(false)
expect(cfg.showMarketing).toBe(false)
})
it('lowercases the configured space slug', () => {
expect(
resolveTenantConfig({ singleTenant: 'true', spaceSlug: 'HeatSync-Labs' })
.spaceSlug,
).toBe('heatsync-labs')
})
it('falls back to null slug when unset or blank', () => {
expect(
resolveTenantConfig({ singleTenant: 'true' }).spaceSlug,
).toBeNull()
expect(
resolveTenantConfig({ singleTenant: 'true', spaceSlug: ' ' }).spaceSlug,
).toBeNull()
})
it('enables open join only when openJoin is exactly "true"', () => {
expect(
resolveTenantConfig({ singleTenant: 'true', openJoin: 'true' }).openJoin,
).toBe(true)
expect(
resolveTenantConfig({ singleTenant: 'true', openJoin: 'false' }).openJoin,
).toBe(false)
expect(
resolveTenantConfig({ singleTenant: 'true', openJoin: 'TRUE' }).openJoin,
).toBe(false)
expect(
resolveTenantConfig({ singleTenant: 'true' }).openJoin,
).toBe(false)
})
})
it('ignores openJoin in multi-tenant mode even when set to "true"', () => {
expect(
resolveTenantConfig({ openJoin: 'true' }).openJoin,
).toBe(false)
expect(
resolveTenantConfig({ singleTenant: 'false', openJoin: 'true' }).openJoin,
).toBe(false)
})
describe('showMarketing explicit override', () => {
it('re-enables marketing in single-tenant mode when set to "true"', () => {
expect(
resolveTenantConfig({ singleTenant: 'true', showMarketing: 'true' })
.showMarketing,
).toBe(true)
})
it('disables marketing in multi-tenant mode when set to "false"', () => {
expect(
resolveTenantConfig({ showMarketing: 'false' }).showMarketing,
).toBe(false)
})
it('treats any non-"true" explicit value as false', () => {
expect(
resolveTenantConfig({ singleTenant: 'true', showMarketing: 'yes' })
.showMarketing,
).toBe(false)
})
})
describe('appUrl normalization', () => {
it('strips trailing slashes', () => {
expect(resolveTenantConfig({ appUrl: 'https://example.org/' }).appUrl).toBe(
'https://example.org',
)
expect(
resolveTenantConfig({ appUrl: 'https://example.org///' }).appUrl,
).toBe('https://example.org')
})
it('trims and falls back to the default when blank', () => {
expect(resolveTenantConfig({ appUrl: '' }).appUrl).toBe(
'http://localhost:3000',
)
expect(resolveTenantConfig({ appUrl: ' ' }).appUrl).toBe(
'http://localhost:3000',
)
})
it('leaves a URL with no trailing slash untouched', () => {
expect(resolveTenantConfig({ appUrl: 'https://example.org' }).appUrl).toBe(
'https://example.org',
)
})
})
describe('siteName', () => {
it('trims whitespace', () => {
expect(resolveTenantConfig({ siteName: ' Acme Space ' }).siteName).toBe(
'Acme Space',
)
})
it('falls back to the default when blank', () => {
expect(resolveTenantConfig({ siteName: '' }).siteName).toBe('hackerspace.sh')
expect(resolveTenantConfig({ siteName: ' ' }).siteName).toBe(
'hackerspace.sh',
)
})
})
})
describe('isMarketingPath', () => {
it('matches marketing roots and their subpaths', () => {
expect(isMarketingPath('/')).toBe(true)
expect(isMarketingPath('/resources')).toBe(true)
expect(isMarketingPath('/resources/foo')).toBe(true)
expect(isMarketingPath('/atlas.html')).toBe(true)
})
it('rejects app paths', () => {
expect(isMarketingPath('/dashboard')).toBe(false)
expect(isMarketingPath('/login')).toBe(false)
})
it('does not treat a prefix collision as a subpath', () => {
expect(isMarketingPath('/resourcesx')).toBe(false)
})
it('matches the root exactly without swallowing every path', () => {
expect(isMarketingPath('/')).toBe(true)
expect(isMarketingPath('/dashboard')).toBe(false)
})
})
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env bash
#
# deploy.sh — production deploy hook, run on the Droplet by .github/workflows/deploy.yml.
#
# The GitHub Actions job SSHes into the Droplet on every push to main and invokes the
# deployed copy of this script (conventionally /opt/hackerspace-ops/deploy.sh). It pulls
# the new code, installs deps, applies any pending DB migrations, refreshes PostgREST's
# schema cache, rebuilds, restarts the app, and finally health-probes it.
#
# Every step is defensive and driven by environment variables with sane defaults, so a
# fresh instance can adopt it by setting at most a couple of vars. It fails fast: any
# step that errors aborts the whole deploy (set -e) and the workflow reports failure.
#
# Environment variables (all optional; defaults match docs/DEPLOY_DO_SELFHOSTED.md):
# APP_DIR app checkout on the Droplet (default /opt/hackerspace)
# DEPLOY_BRANCH branch to deploy (default main)
# SUPABASE_DIR self-hosted Supabase compose dir (default /opt/supabase/docker)
# SUPABASE_DB_CONTAINER Postgres compose service for migrations (default db)
# DATABASE_URL alternative to the two vars above; if set, migrations use it
# directly and Supabase-container steps are skipped
# APP_SERVICE docker compose service name for the app (default app)
# SYSTEMD_APP_UNIT systemd unit name for a non-Docker app deploy (default hackerspace-app)
# SYSTEMD_REST_UNIT systemd unit for PostgREST when not on Docker (default postgrest)
# HEALTH_URL local health endpoint to probe (default http://localhost:3000/api/health)
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/hackerspace}"
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
SUPABASE_DIR="${SUPABASE_DIR:-/opt/supabase/docker}"
SUPABASE_DB_CONTAINER="${SUPABASE_DB_CONTAINER:-db}"
APP_SERVICE="${APP_SERVICE:-app}"
SYSTEMD_APP_UNIT="${SYSTEMD_APP_UNIT:-hackerspace-app}"
SYSTEMD_REST_UNIT="${SYSTEMD_REST_UNIT:-postgrest}"
HEALTH_URL="${HEALTH_URL:-http://localhost:3000/api/health}"
log() { printf '[deploy %s] %s\n' "$(date -u +%H:%M:%S)" "$*"; }
# ---------------------------------------------------------------------------
# 1. Enter the app checkout.
# ---------------------------------------------------------------------------
log "cd to app dir: $APP_DIR"
cd "$APP_DIR"
# ---------------------------------------------------------------------------
# 2. Fetch and hard-reset to the deployed branch. reset --hard (not pull) so a
# dirty working tree or a force-push never leaves the checkout in a half state.
# ---------------------------------------------------------------------------
log "git fetch + reset to origin/$DEPLOY_BRANCH"
git fetch --prune origin "$DEPLOY_BRANCH"
git reset --hard "origin/$DEPLOY_BRANCH"
# ---------------------------------------------------------------------------
# 3. Install dependencies exactly as locked. --frozen-lockfile fails if the
# lockfile is out of sync rather than silently resolving new versions.
# ---------------------------------------------------------------------------
log "pnpm install --frozen-lockfile"
pnpm install --frozen-lockfile
# ---------------------------------------------------------------------------
# 4. Apply pending DB migrations. apply-migrations.sh is idempotent; it reads
# DATABASE_URL if set, otherwise talks to the Supabase Postgres container.
# ---------------------------------------------------------------------------
log "applying database migrations"
if [[ -n "${DATABASE_URL:-}" ]]; then
DATABASE_URL="$DATABASE_URL" bash "$APP_DIR/scripts/apply-migrations.sh"
else
SUPABASE_DIR="$SUPABASE_DIR" SUPABASE_DB_CONTAINER="$SUPABASE_DB_CONTAINER" \
bash "$APP_DIR/scripts/apply-migrations.sh"
fi
# ---------------------------------------------------------------------------
# 5. Restart PostgREST so it refreshes its cached schema after any DDL.
# Documented both ways: the self-hosted Docker Supabase stack exposes the
# PostgREST service as "rest"; a systemd-managed PostgREST is restarted by unit.
# ---------------------------------------------------------------------------
log "restarting PostgREST to refresh its schema cache"
if [[ -f "$SUPABASE_DIR/docker-compose.yml" ]]; then
docker compose -f "$SUPABASE_DIR/docker-compose.yml" restart rest
elif command -v systemctl >/dev/null 2>&1; then
# Non-Docker alternative: a systemd-managed PostgREST unit.
systemctl restart "$SYSTEMD_REST_UNIT"
else
log "warning: no $SUPABASE_DIR/docker-compose.yml and no systemctl; skipping PostgREST restart"
log " restart PostgREST manually so it picks up schema changes"
fi
# ---------------------------------------------------------------------------
# 6. Build the app.
# ---------------------------------------------------------------------------
log "pnpm build"
pnpm build
# ---------------------------------------------------------------------------
# 7. Restart the app. Prefer a Docker Compose rebuild when this is a
# Dockerfile-based deploy; otherwise fall back to the systemd unit.
# ---------------------------------------------------------------------------
log "restarting the app"
if [[ -f "$APP_DIR/Dockerfile" ]] && command -v docker >/dev/null 2>&1; then
docker compose up -d --build "$APP_SERVICE"
elif command -v systemctl >/dev/null 2>&1; then
systemctl restart "$SYSTEMD_APP_UNIT"
else
log "error: cannot restart the app (no Dockerfile-based deploy and no systemctl)" >&2
exit 1
fi
# ---------------------------------------------------------------------------
# 8. Health probe. Give the freshly restarted app a few seconds to come up,
# then require a 200 from the local health endpoint.
# ---------------------------------------------------------------------------
log "probing health at $HEALTH_URL"
health_code=""
for attempt in 1 2 3 4 5 6 7 8 9 10; do
health_code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$HEALTH_URL" || true)"
if [[ "$health_code" == "200" ]]; then
break
fi
log " attempt $attempt: got '${health_code:-no response}', retrying in 3s"
sleep 3
done
if [[ "$health_code" != "200" ]]; then
log "error: health check failed (last status: '${health_code:-no response}')" >&2
exit 1
fi
log "health check passed (200)"
log "deploy complete"
+464
View File
@@ -0,0 +1,464 @@
# =============================================================================
# docker-compose.full.yml - CONVENIENCE BUNDLE (app + self-hosted Supabase)
# =============================================================================
# One command brings up the Next.js app PLUS a self-hosted Supabase stack
# (Postgres, GoTrue auth, PostgREST, Realtime, the Kong API gateway, and
# Studio) so a single-tenant deployer gets application + database together:
#
# cp .env.full.example .env.full # then fill in the secrets
# docker compose -f docker-compose.full.yml --env-file .env.full up -d
#
# This is the fast path for a laptop, a demo box, or a small single-tenant
# instance. It is deliberately opinionated and NOT hardened:
# - no Nginx / TLS termination (add your own reverse proxy for the internet),
# - no Storage / Edge Functions / analytics (not needed to run this app),
# - Kong's declarative config is generated inline instead of from a vendored
# file, so everything lives in these two files.
#
# For the production, hardened, internet-facing deployment (separate networks,
# Nginx + Let's Encrypt, backups, Studio behind basic-auth, the full Supabase
# compose) follow docs/DEPLOY_DO_SELFHOSTED.md. That guide is the source of
# truth for real deployments; this bundle is for convenience only.
#
# After the stack is healthy you still apply the app schema exactly once
# (see docs/DEPLOY_DO_SELFHOSTED.md section 9):
# docker compose -f docker-compose.full.yml --env-file .env.full \
# exec -T db psql -U postgres -d "$POSTGRES_DB" < scripts/schema.sql
# # then each numbered scripts/0NN_*.sql, then: ... restart rest
# =============================================================================
name: hackerspace-full
services:
# ---------------------------------------------------------------------------
# Postgres - the one durable service. Everything else is stateless.
# supabase/postgres ships the roles (anon, authenticated, service_role,
# authenticator, supabase_admin, supabase_auth_admin, ...) and the logical
# replication config that GoTrue / PostgREST / Realtime expect on first boot.
# ---------------------------------------------------------------------------
db:
image: supabase/postgres:15.8.1.060
container_name: hackerspace_db
restart: unless-stopped
command:
- postgres
- -c
- wal_level=logical
- -c
- max_replication_slots=5
- -c
- max_wal_senders=5
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_PORT: "5432"
JWT_SECRET: ${JWT_SECRET}
JWT_EXP: ${JWT_EXPIRY}
ports:
# Admin port: bound to localhost only (psql / pg_dump from the host).
- "127.0.0.1:${POSTGRES_PORT}:5432"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -h localhost -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
start_period: 30s
# ---------------------------------------------------------------------------
# GoTrue (auth) - /auth/v1/* behind Kong.
# ---------------------------------------------------------------------------
auth:
image: supabase/gotrue:v2.177.0
container_name: hackerspace_auth
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: "9999"
API_EXTERNAL_URL: ${API_EXTERNAL_URL}
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
GOTRUE_SITE_URL: ${SITE_URL}
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
GOTRUE_DISABLE_SIGNUP: "false"
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_EXP: ${JWT_EXPIRY}
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
GOTRUE_MAILER_AUTOCONFIRM: ${ENABLE_EMAIL_AUTOCONFIRM}
GOTRUE_EXTERNAL_PHONE_ENABLED: "false"
GOTRUE_SMS_AUTOCONFIRM: "false"
GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
GOTRUE_SMTP_HOST: ${SMTP_HOST}
GOTRUE_SMTP_PORT: ${SMTP_PORT}
GOTRUE_SMTP_USER: ${SMTP_USER}
GOTRUE_SMTP_PASS: ${SMTP_PASS}
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}
GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify
GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify
GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:9999/health",
]
interval: 5s
timeout: 5s
retries: 10
start_period: 20s
# ---------------------------------------------------------------------------
# PostgREST (rest) - /rest/v1/* behind Kong.
# The postgrest image is a distroless Haskell binary with no shell/curl, so
# it cannot self-healthcheck. Readiness for the app is therefore gated on
# Kong (which fronts rest); see the app service's depends_on below.
# ---------------------------------------------------------------------------
rest:
image: postgrest/postgrest:v12.2.12
container_name: hackerspace_rest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: ${JWT_SECRET}
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}
command: ["postgrest"]
# ---------------------------------------------------------------------------
# Realtime - /realtime/v1/* behind Kong. The container name is significant:
# Kong routes to realtime-dev.supabase-realtime, matching upstream Supabase.
# ---------------------------------------------------------------------------
realtime:
image: supabase/realtime:v2.34.47
container_name: realtime-dev.supabase-realtime
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PORT: "4000"
DB_HOST: db
DB_PORT: "5432"
DB_USER: supabase_admin
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: ${POSTGRES_DB}
DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
DB_ENC_KEY: supabaserealtime
API_JWT_SECRET: ${JWT_SECRET}
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
ERL_AFLAGS: -proto_dist inet_tcp
DNS_NODES: "''"
RLIMIT_NOFILE: "10000"
APP_NAME: realtime
SEED_SELF_HOST: "true"
RUN_JANITOR: "true"
healthcheck:
test:
[
"CMD",
"curl",
"-sSfL",
"--head",
"-o",
"/dev/null",
"http://localhost:4000/api/tenants/realtime-dev/health",
]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
# ---------------------------------------------------------------------------
# postgres-meta - backs Studio's table editor. Not fronted by Kong here.
# ---------------------------------------------------------------------------
meta:
image: supabase/postgres-meta:v0.91.0
container_name: hackerspace_meta
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PG_META_PORT: "8080"
PG_META_DB_HOST: db
PG_META_DB_PORT: "5432"
PG_META_DB_NAME: ${POSTGRES_DB}
PG_META_DB_USER: supabase_admin
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
# ---------------------------------------------------------------------------
# Kong - the API gateway. Everything the app / browser talks to goes here:
# /auth/v1/* -> auth:9999
# /rest/v1/* -> rest:3000
# /realtime/v1/* -> realtime:4000
# Declarative (DB-less) config is generated inline from the ANON_KEY /
# SERVICE_ROLE_KEY env vars so this bundle needs no vendored kong.yml.
# NOTE: $$VAR below is a literal `$VAR` after compose interpolation; the
# heredoc runs in the container shell, which expands it from Kong's env.
# ---------------------------------------------------------------------------
kong:
image: kong:2.8.1
container_name: hackerspace_kong
restart: unless-stopped
depends_on:
auth:
condition: service_healthy
rest:
condition: service_started
realtime:
condition: service_started
ports:
# API gateway. Bound to localhost: a reverse proxy (or the browser on the
# same host) reaches it here. Do NOT expose 0.0.0.0 on the public internet
# without TLS in front - see docs/DEPLOY_DO_SELFHOSTED.md.
- "127.0.0.1:${KONG_HTTP_PORT}:8000"
- "127.0.0.1:${KONG_HTTPS_PORT}:8443"
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
KONG_DNS_ORDER: LAST,A,CNAME
KONG_PLUGINS: request-transformer,cors,key-auth,acl
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
entrypoint:
- bash
- -c
- |
set -e
cat > /home/kong/kong.yml <<EOF
_format_version: '2.1'
_transform: true
consumers:
- username: anon
keyauth_credentials:
- key: $$SUPABASE_ANON_KEY
- username: service_role
keyauth_credentials:
- key: $$SUPABASE_SERVICE_KEY
acls:
- consumer: anon
group: anon
- consumer: service_role
group: admin
services:
- name: auth-v1-open
url: http://auth:9999/verify
routes:
- name: auth-v1-open
strip_path: true
paths:
- /auth/v1/verify
plugins:
- name: cors
- name: auth-v1-open-callback
url: http://auth:9999/callback
routes:
- name: auth-v1-open-callback
strip_path: true
paths:
- /auth/v1/callback
plugins:
- name: cors
- name: auth-v1-open-authorize
url: http://auth:9999/authorize
routes:
- name: auth-v1-open-authorize
strip_path: true
paths:
- /auth/v1/authorize
plugins:
- name: cors
- name: auth-v1
url: http://auth:9999/
routes:
- name: auth-v1-all
strip_path: true
paths:
- /auth/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: rest-v1
url: http://rest:3000/
routes:
- name: rest-v1-all
strip_path: true
paths:
- /rest/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: true
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
- name: realtime-v1
url: http://realtime-dev.supabase-realtime:4000/socket/
routes:
- name: realtime-v1-all
strip_path: true
paths:
- /realtime/v1/
plugins:
- name: cors
- name: key-auth
config:
hide_credentials: false
- name: acl
config:
hide_groups_header: true
allow:
- admin
- anon
EOF
exec /docker-entrypoint.sh kong docker-start
healthcheck:
test: ["CMD", "kong", "health"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
# ---------------------------------------------------------------------------
# Studio - the admin dashboard. Bound to localhost only; put it behind a
# reverse proxy with auth before exposing it (see the DEPLOY doc, section 11).
# ---------------------------------------------------------------------------
studio:
image: supabase/studio:2025.06.30-sha-6f5982d
container_name: hackerspace_studio
restart: unless-stopped
depends_on:
kong:
condition: service_healthy
meta:
condition: service_started
ports:
# Admin dashboard: localhost only.
- "127.0.0.1:${STUDIO_PORT}:3000"
environment:
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${JWT_SECRET}
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:3000/api/platform/profile').then((r)=>{if(r.status!==200)throw new Error(r.status)}).catch(()=>process.exit(1))",
]
interval: 10s
timeout: 10s
retries: 5
start_period: 30s
# ---------------------------------------------------------------------------
# The app - built from the existing Dockerfile. NEXT_PUBLIC_* values are
# inlined at build time (Next.js), so they are passed as build args AND at
# runtime. Point NEXT_PUBLIC_SUPABASE_URL at the browser-reachable Kong URL
# (http://localhost:8000 for a local bundle).
# ---------------------------------------------------------------------------
app:
build:
context: .
dockerfile: Dockerfile
args:
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
image: hackerspace-management:latest
container_name: hackerspace_app
restart: unless-stopped
depends_on:
# App waits until the API gateway is healthy. Kong fronts rest/auth/
# realtime, so a healthy Kong means the data plane is reachable. rest has
# no in-image shell to self-healthcheck, so it is gated here as started
# and effectively covered by Kong's own upstream readiness.
kong:
condition: service_healthy
auth:
condition: service_healthy
rest:
condition: service_started
ports:
- "3000:3000"
environment:
NODE_ENV: production
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
# Single-tenant knobs (optional; see .env.full.example / docs/SINGLE_TENANT.md)
NEXT_PUBLIC_SINGLE_TENANT: ${NEXT_PUBLIC_SINGLE_TENANT}
NEXT_PUBLIC_SITE_NAME: ${NEXT_PUBLIC_SITE_NAME}
NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG: ${NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG}
NEXT_PUBLIC_SINGLE_TENANT_OPEN_JOIN: ${NEXT_PUBLIC_SINGLE_TENANT_OPEN_JOIN}
NEXT_PUBLIC_SHOW_MARKETING: ${NEXT_PUBLIC_SHOW_MARKETING}
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://localhost:3000/api/health",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
volumes:
# Postgres data survives `docker compose down`. Use `down -v` to wipe it.
db-data:
networks:
default:
name: hackerspace_full_net
+108
View File
@@ -0,0 +1,108 @@
# Customizing your instance
How to white-label and extend a self-hosted instance. The rule this app follows: all deployment configuration lives in one place (`lib/tenant.ts`), is resolved by a pure function, and is consumed through a single accessor. Follow that pattern and every customization stays testable and consistent.
This guide assumes you are running your own instance (see [SINGLE_TENANT.md](./SINGLE_TENANT.md)). Most customization applies to any deployment.
## Branding
**Name.** Set `NEXT_PUBLIC_SITE_NAME` to your space's name. It flows through `tenantConfig().siteName` into the UI wordmark and into the page title (the browser tab title is composed from `siteName` in `app/layout.tsx`). It defaults to `hackerspace.sh` when unset. This is a `NEXT_PUBLIC_` value, so set it before `pnpm build`.
**Logo.** There are two separate marks. `public/logo.svg` backs only the browser-tab icons (favicon, Apple touch icon, shortcut icon), wired up in the `icons` block of `app/layout.tsx`; replace that file (keeping the filename) to change what shows in the browser tab. The mark shown inside the app chrome (sidebar, landing, docs, onboarding) is a separate inline SVG in `components/brand-mark.tsx` and must be edited there. Update both if you want a fully white-labeled logo.
**Page title.** The metadata `title` and `description` live in `app/layout.tsx`. The title interpolates `siteName`; edit the surrounding text there if you want a different suffix.
## Base URL and links
Set `NEXT_PUBLIC_APP_URL` to your canonical public URL, with no trailing slash. Every absolute link the app emits (dues emails, form-result links, the `/me` self-serve portal, OAuth redirects) is built from `appBaseUrl()`, which returns `tenantConfig().appUrl`. The resolver strips a trailing slash so callers can safely template `` `${appBaseUrl()}/path` ``.
Never hardcode a domain. When you add a feature that emits an absolute URL, import `appBaseUrl` from `@/lib/tenant` and build the link from it. That is what keeps a single-tenant instance from ever leaking the platform domain.
## The configuration pattern
`lib/tenant.ts` is the single source of truth for deployment configuration. It has three parts, and every flag follows the same shape as the existing ones (which mirror `lib/auth-config.ts`):
1. **`TenantConfig`** is the typed, resolved config the rest of the app consumes.
2. **`resolveTenantConfig(env)`** is a pure function: raw flag strings in, resolved config out, no `process.env` access. All defaulting and precedence lives here, so it is trivially unit-testable.
3. **`tenantConfig()`** is a thin wrapper that reads the `process.env.NEXT_PUBLIC_*` values (written as literals so Next.js inlines them into the client bundle) and passes them to the pure resolver.
### Adding a new deployment-config flag
Say you want a `showRecruitment` flag that toggles the public recruitment page. The steps, in order:
1. **Add a field to `TenantConfig`.** Give it a resolved type and a doc comment:
```ts
export type TenantConfig = {
// ...existing fields...
/** Whether the public recruitment page is served. */
showRecruitment: boolean
}
```
2. **Add the raw input to `TenantEnv`** (all inputs are optional strings, because that is what `process.env` gives you):
```ts
export type TenantEnv = {
// ...existing fields...
showRecruitment?: string
}
```
3. **Add the rule to `resolveTenantConfig`.** This is where defaulting and precedence live. Keep it pure:
```ts
return {
// ...existing fields...
showRecruitment: env.showRecruitment !== undefined
? isTrue(env.showRecruitment)
: true, // default on
}
```
4. **Unit-test the pure resolver** in `__tests__/tenant.test.ts`, following the style of `__tests__/auth-config.test.ts`: assert the default, assert that exactly `'true'` enables it, and assert that empty or other values are treated as off. Because the resolver takes no `process.env`, the test passes plain objects in.
```ts
it('defaults showRecruitment on and toggles only on "true"', () => {
expect(resolveTenantConfig({}).showRecruitment).toBe(true)
expect(resolveTenantConfig({ showRecruitment: 'false' }).showRecruitment).toBe(false)
expect(resolveTenantConfig({ showRecruitment: '1' }).showRecruitment).toBe(false)
})
```
5. **Read the env var in `tenantConfig()`.** Use a literal `process.env.NEXT_PUBLIC_*` member expression so Next.js can inline it:
```ts
return resolveTenantConfig({
// ...existing reads...
showRecruitment: process.env.NEXT_PUBLIC_SHOW_RECRUITMENT,
})
```
6. **Consume it.** In a server or client component, `const { showRecruitment } = tenantConfig()` and branch on it. Document the new variable in `.env.example` and in the env table in `SINGLE_TENANT.md`.
### `NEXT_PUBLIC_` versus server-only
This distinction decides whether a flag can be a security boundary.
- **`NEXT_PUBLIC_` variables are inlined into the client bundle at build time.** They are visible to and forgeable by the browser, and changing one requires a rebuild and redeploy (a running build does not re-read them). Use the prefix only when a client component actually needs the value (for example the signup page, which is a client component). Because the browser can forge them, any rule they express as UX must be re-enforced on the server. The existing flags do exactly this: `createSpace` re-checks `allowSpaceCreation`, `joinSpace` re-checks the open-join rules, and `proxy.ts` re-checks marketing visibility. If your flag gates access to data or an action, add the same server-side check in the relevant server action or route handler. Never treat a `NEXT_PUBLIC_` flag as an authorization boundary on its own.
- **Server-only variables (no `NEXT_PUBLIC_` prefix)** never reach the browser. They are read at request time in server code only (server actions, route handlers, `proxy.ts`). Use these for secrets and for anything the client must not see or influence (`SUPABASE_SERVICE_ROLE_KEY`, `CRON_SECRET`, `SECRETS_ENCRYPTION_KEY`, `RESEND_API_KEY`). A server-only flag read in server code is a real trust boundary.
Rule of thumb: if the browser needs to know it to render, it is `NEXT_PUBLIC_` and must be re-enforced server-side. Otherwise keep it server-only.
## Theme and colors
Colors and theme tokens live in `app/globals.css` as CSS custom properties on `:root` (light) and `.dark` (dark). Values are in the `oklch` color space. The brand accent is `--primary` (a green), with matching `--ring`, `--accent`, and `--sidebar-primary`; `--chart-1` through `--chart-5` drive the data visualizations, and there are dedicated `--auth-accent` / `--auth-bg` tokens for the login and signup screens. Change these variables to restyle the whole app; every component reads them through Tailwind.
This project uses Tailwind v4 configured in CSS (`@import 'tailwindcss'` at the top of `app/globals.css`), so there is no `tailwind.config.js` to edit. Extend the theme in `globals.css`.
## Replacing the marketing shell
If you want to keep marketing on but swap in your own landing page, the shell lives under route groups (the group name in parentheses adds no URL segment):
- `app/(landing)/page.tsx` is the landing page (`/`), with `app/(landing)/layout.tsx` and `app/(landing)/landing.css` for its shell and styles.
- `app/(resources)/` holds the resources subsite (`/resources`, `/zine`, `/governance`, `/space-after-dark`, `/proposal-duel`, `/atlas`), with its own `layout.tsx` and `resources.css`.
- `components/landing/` and `components/brand-mark.tsx` are the reusable pieces (hero, showcase, icons, wordmark).
Edit `app/(landing)/page.tsx` to replace the landing content with your own. To hide the whole marketing shell instead, set `NEXT_PUBLIC_SHOW_MARKETING=false` (the default in single-tenant mode); `proxy.ts` then redirects those paths to `/login`. See [SINGLE_TENANT.md](./SINGLE_TENANT.md) for the marketing behavior.
+153
View File
@@ -0,0 +1,153 @@
# Single-tenant mode
Run this app as a private instance for exactly one hackerspace, white-labeled with your own name and domain.
By default the app is the multi-tenant platform (the hackerspace.sh model): one deployment hosts many independent spaces, and anyone can create or join a space at signup. Single-tenant mode turns the same codebase into a dedicated, siloed instance for one organization.
## What single-tenant mode is
The data model does not change. Every table is still keyed by `space_id`, row-level security is still mandatory, and the app still runs against exactly one space either way. Single-tenant mode is a thin layer of deployment configuration (resolved in `lib/tenant.ts`) that:
1. Forbids creating a second space (the "create a space" flow is removed from the UI and the `createSpace` server action refuses).
2. Turns signup into "join THE space" instead of "create or join".
3. Optionally opens join without an invite code (still subject to the space's approval gate).
4. Hides the hackerspace.sh marketing and landing shell.
5. Lets you white-label the brand name and canonical base URL.
Think of it as pooled platform versus siloed instance. One org, one space, one deployment, your domain.
## Quick start
The infrastructure (Supabase plus the Next.js app) is provisioned exactly the same way as any deployment. Only the environment variables and the one-time setup step differ.
1. **Provision Supabase and the app.** Follow the self-hosted guide end to end: [DEPLOY_DO_SELFHOSTED.md](./DEPLOY_DO_SELFHOSTED.md) (single DigitalOcean Droplet, self-hosted Supabase behind Nginx). For the managed-Supabase path see [DEPLOYMENT.md](./DEPLOYMENT.md).
2. **Apply the database schema.** Run `scripts/schema.sql` once against the fresh Postgres database, then apply the numbered migrations (`scripts/0NN_*.sql`) in order. Each migration is applied exactly once and recorded in `public._migrations_applied`. Restart PostgREST afterward so it refreshes its schema cache. See section 9 of [DEPLOY_DO_SELFHOSTED.md](./DEPLOY_DO_SELFHOSTED.md) and [scripts/README.md](../scripts/README.md).
3. **Set the environment variables.** Fill in the required runtime keys plus the single-tenant keys (see the minimal example below and the [full table](#single-tenant-environment-variables)).
4. **Create the space and first admin.** Run `pnpm setup` once. It provisions your one space (the same shape `createSpace` produces, with the default channels, areas, and tiers auto-created by triggers) and creates the first admin login through the Supabase Admin API. See the [CLI reference](#the-setup-cli).
5. **Sign in.** Open your `NEXT_PUBLIC_APP_URL`, go to `/login`, and sign in with the admin email and password you set. You land on the dashboard as the founding admin.
### Minimal `.env.local`
Copy `.env.example` and fill in at least these keys. The `NEXT_PUBLIC_` values are inlined into the client bundle at build time, so a production instance must have them set before `pnpm build`.
```bash
# Required runtime
NEXT_PUBLIC_SUPABASE_URL=https://supabase.yourdomain.com
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...replace-with-anon-key
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...replace-with-service-role-key
NEXT_PUBLIC_APP_URL=https://members.yourdomain.com
# Single-tenant mode
NEXT_PUBLIC_SINGLE_TENANT=true
NEXT_PUBLIC_SITE_NAME=Your Space Name
NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG=yourspace
NEXT_PUBLIC_SINGLE_TENANT_OPEN_JOIN=true
# Used ONLY by `pnpm setup`, never read at runtime.
# Fill these in, run the CLI once, then you can clear the password.
SETUP_SPACE_NAME=Your Space Name
SETUP_SPACE_SLUG=yourspace
SETUP_SPACE_CITY=Your City
SETUP_ADMIN_EMAIL=you@yourdomain.com
SETUP_ADMIN_PASSWORD=pick-a-strong-password
SETUP_ADMIN_NAME=Your Name
```
`SETUP_SPACE_SLUG` must match `NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG`. The tenant resolver lowercases the runtime slug, so keep the setup slug lowercase to avoid a mismatch. If you leave `NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG` unset, the join flow falls back to "the only space in the database", which is fine for a true single-tenant instance.
## The setup CLI
`pnpm setup` (which runs `node scripts/setup.mjs`) is the one-time bootstrap. It reads the `SETUP_*` variables from your environment (or `.env.local`), or takes them from CLI flags, or prompts for them interactively. Precedence is flag, then environment variable, then interactive prompt. It never reads the `SETUP_*` values at runtime; they exist only for this CLI.
It uses `SUPABASE_SERVICE_ROLE_KEY` to talk to the database and to GoTrue, so that key must be set.
### Subcommands
| Command | What it does |
|---------|--------------|
| `pnpm setup` | Full bootstrap: run `doctor`, then `provision` the space, then `create-admin`. Safe to re-run. |
| `pnpm setup doctor` | Checks only. Verifies the Supabase URL and service-role key connect, that the `spaces` table exists (schema applied), and reports whether the space and the admin already exist. Makes no changes. |
| `pnpm setup provision` | Creates the one space only. Inserts the `spaces` row (`name`, `slug`, `city`, generated `invite_code`); the default comms channels, areas, and tiers are created by database triggers, not by the CLI. |
| `pnpm setup create-admin` | Creates the first admin only. Creates the auth user through the Supabase Admin API (email confirmed) and inserts the founder `space_members` row (`role: 'admin'`, `tier: 'plus'`, `status: 'current'`, approved and onboarded). |
### Flags
Every `SETUP_*` variable has a matching flag; the flag wins over the environment variable.
| Flag | Environment variable |
|------|----------------------|
| `--space-name` | `SETUP_SPACE_NAME` |
| `--space-slug` | `SETUP_SPACE_SLUG` |
| `--space-city` | `SETUP_SPACE_CITY` |
| `--admin-email` | `SETUP_ADMIN_EMAIL` |
| `--admin-password` | `SETUP_ADMIN_PASSWORD` |
| `--admin-name` | `SETUP_ADMIN_NAME` |
| `--yes` | (non-interactive: never prompt, error on any missing required value, auto-generate an admin password if none is given) |
| `--force` | (guard, see below) |
### Idempotency and the `--force` guard
The CLI is safe to re-run. `provision` looks up the space by slug first: if it already exists it is left untouched. `create-admin` looks up the admin by email: if that user already exists the CLI stops rather than creating a duplicate or silently changing the password.
`--force` is the explicit override for that guard. Use it only when you mean to reset an existing bootstrap (for example to set a new admin password on an existing account). Without `--force`, an already-provisioned instance is a no-op, which is what you want for a redeploy.
Because GoTrue owns password hashing, the admin auth user is always created through the Admin API (`auth.admin.createUser` with `email_confirm: true`). The CLI never writes to `auth.users` directly.
## Deploy
Single-tenant instances deploy exactly like any instance. The moving parts:
- `deploy/deploy.sh` runs the standard release: fetch, install, apply pending migrations, build, restart. It is idempotent, so a redeploy with no new migrations does not touch the database.
- `scripts/apply-migrations.sh` applies any `scripts/0NN_*.sql` not yet recorded in `public._migrations_applied` and records each filename. It does not restart PostgREST; do that separately afterward so PostgREST refreshes its schema cache (`deploy/deploy.sh` does this automatically, or run `docker compose restart rest` when applying migrations by hand).
- `docker-compose.full.yml` brings up the full stack (Supabase plus the app) for a container-based host.
Set every `NEXT_PUBLIC_` value on the host before building, because Next.js inlines them at build time. Run `pnpm setup` once after the first successful deploy (it is a no-op on later deploys). Full procedure: [DEPLOY_DO_SELFHOSTED.md](./DEPLOY_DO_SELFHOSTED.md).
## How single-tenant differs at runtime
| Behavior | Multi-tenant (default) | Single-tenant |
|----------|------------------------|---------------|
| Create a space | Available in signup UI; `createSpace` allowed | Removed from UI; `createSpace` refuses |
| Signup flow | Create or join a space | Join THE space |
| Join without invite code | Never (invite code is the whole join model) | Allowed when `NEXT_PUBLIC_SINGLE_TENANT_OPEN_JOIN=true` |
| Marketing and landing shell | Served | Hidden (redirected to `/login`) |
| Brand name and base URL | hackerspace.sh defaults | White-labeled via env |
These runtime rules are enforced on the server, not just in the UI. The `NEXT_PUBLIC_` flags are visible to and forgeable by the browser, so `createSpace` re-checks `allowSpaceCreation`, `joinSpace` re-checks the open-join and slug rules, and `proxy.ts` redirects marketing paths server-side. Treat the client-side config as UX only.
### Open join and approval
With `NEXT_PUBLIC_SINGLE_TENANT_OPEN_JOIN=true`, a new person can sign up and join your space with an empty invite code. This does not bypass approval: if the space has `require_approval` on, the new member lands in the `unverified` state and an admin approves them from the members page. Leave the flag unset to require an invite code even in single-tenant mode.
### Marketing shell
When marketing is hidden, `proxy.ts` redirects the marketing paths (the landing page `/`, and `/resources`, `/zine`, `/governance`, `/space-after-dark`, `/proposal-duel`, `/atlas`, `/atlas.html`) to `/login`. Marketing defaults off in single-tenant mode; set `NEXT_PUBLIC_SHOW_MARKETING=true` to re-enable the landing and resources shell on your instance.
## Single-tenant environment variables
All of these are `NEXT_PUBLIC_` (inlined at build time, so changing one requires a rebuild) and all are optional except `NEXT_PUBLIC_APP_URL`, which is required for every deployment. Values are resolved in `lib/tenant.ts`.
| Variable | Purpose | Default when unset |
|----------|---------|--------------------|
| `NEXT_PUBLIC_SINGLE_TENANT` | Set to `true` to run as a single-tenant instance. Anything else is multi-tenant. | multi-tenant (`false`) |
| `NEXT_PUBLIC_SITE_NAME` | Brand name shown in the UI wordmark and the page title. | `hackerspace.sh` |
| `NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG` | Slug of the one space new signups join (matches `spaces.slug`). Lowercased by the resolver. | the only space in the database |
| `NEXT_PUBLIC_SINGLE_TENANT_OPEN_JOIN` | Set to `true` to allow join without an invite code. Ignored in multi-tenant. | invite code required (`false`) |
| `NEXT_PUBLIC_SHOW_MARKETING` | Override marketing shell visibility. | off in single-tenant, on in multi-tenant |
| `NEXT_PUBLIC_APP_URL` | Canonical public base URL, no trailing slash. Used for absolute links (emails, form results, OAuth redirects). | `http://localhost:3000` |
Setup-only variables (`SETUP_SPACE_NAME`, `SETUP_SPACE_SLUG`, `SETUP_SPACE_CITY`, `SETUP_ADMIN_EMAIL`, `SETUP_ADMIN_PASSWORD`, `SETUP_ADMIN_NAME`) are read only by `pnpm setup`, never at runtime. See the [CLI reference](#the-setup-cli).
## Troubleshooting
**`pnpm setup` cannot find the `spaces` table.** The schema has not been applied to the database the CLI is pointed at, or PostgREST is serving a stale cache. Confirm `NEXT_PUBLIC_SUPABASE_URL` and `SUPABASE_SERVICE_ROLE_KEY` point at the right project, run `scripts/schema.sql` and the numbered migrations, then restart PostgREST (`docker compose restart rest`). Re-run `pnpm setup doctor` to confirm.
**Admin already exists.** `create-admin` refuses to duplicate an existing auth user, so a second run reports the admin is already present and stops. This is expected on a redeploy. If you genuinely need to reset the password on that account, re-run with `--force`. To add a different admin, use a different `SETUP_ADMIN_EMAIL`, or invite them from the members page once you are signed in.
**Marketing pages 404 or redirect to `/login`.** That is the intended single-tenant behavior: with marketing hidden, `proxy.ts` redirects every marketing path to `/login`. If you want the landing and resources shell served on your instance, set `NEXT_PUBLIC_SHOW_MARKETING=true` and rebuild (it is a `NEXT_PUBLIC_` value, so a running build will not pick up the change until you rebuild and redeploy).
**Links point at localhost or hackerspace.sh.** `NEXT_PUBLIC_APP_URL` is unset or wrong. Every absolute link the app emits comes from `appBaseUrl()`, which reads this value. Set it to your real domain with no trailing slash, then rebuild and redeploy so the new value is inlined. If you changed it but old links persist, you are serving a build made before the change.
+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 you@example.org
// 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 you@example.org
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)
})