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:
@@ -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=
|
||||
@@ -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.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Executable
+129
@@ -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"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
Executable
+103
@@ -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
@@ -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
|
||||
|
||||
Executable
+690
@@ -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)
|
||||
})
|
||||
Reference in New Issue
Block a user