diff --git a/.env.example b/.env.example
index 7215173..cf2482d 100644
--- a/.env.example
+++ b/.env.example
@@ -14,9 +14,58 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...replace-with-anon-key
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...replace-with-service-role-key
# App URL (required)
-# Public-facing base URL of this deployment. Used in OAuth redirects.
+# Public-facing base URL of this deployment. Used in OAuth redirects and in every
+# absolute link the app emits (dues emails, form-result links, the /me portal).
+# Set this to your own domain: it replaces the old hackerspace.sh fallback, so a
+# single-tenant instance never leaks the platform domain. No trailing slash.
NEXT_PUBLIC_APP_URL=http://localhost:3000
+# -----------------------------------------------------------------------------
+# Single-tenant mode (optional). See docs/SINGLE_TENANT.md for the full guide.
+# -----------------------------------------------------------------------------
+# Leave everything below unset to run the default MULTI-TENANT platform (one
+# deployment hosts many spaces, users create/join spaces at signup — the
+# hackerspace.sh model). Set NEXT_PUBLIC_SINGLE_TENANT=true to run a dedicated
+# instance for exactly ONE space:
+# - the "create a space" flow is disabled (server + UI),
+# - signup becomes "join THE space",
+# - the hackerspace.sh marketing/landing shell is hidden.
+# These are NEXT_PUBLIC_ because the signup page (a client component) reads them;
+# the server re-enforces every rule, so they are safe to expose.
+NEXT_PUBLIC_SINGLE_TENANT=
+
+# Brand name shown throughout the UI (logo wordmark, page titles). White-label
+# your instance here. Defaults to "hackerspace.sh" when unset.
+NEXT_PUBLIC_SITE_NAME=
+
+# The slug of your one space (matches spaces.slug, created by `pnpm setup`).
+# In single-tenant mode new signups join this space. If unset, the app falls
+# back to "the only space in the database".
+NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG=
+
+# Set to "true" to let people sign up and join your space WITHOUT an invite code
+# (still subject to the space's require_approval setting — approve them from the
+# members page). Leave unset to require an invite code even in single-tenant mode.
+NEXT_PUBLIC_SINGLE_TENANT_OPEN_JOIN=
+
+# Override the marketing shell visibility. Defaults to ON for the platform and
+# OFF in single-tenant mode. Set "true" to keep the /resources subsite + landing
+# page, or "false" to hide them on a multi-tenant deploy.
+NEXT_PUBLIC_SHOW_MARKETING=
+
+# -----------------------------------------------------------------------------
+# First-admin bootstrap (used ONLY by `pnpm setup`, never read at runtime).
+# `pnpm setup` provisions your one space and creates the first admin login from
+# these values (or from interactive prompts / CLI flags). Fill them in, run the
+# CLI once, then you can clear the password. See docs/SINGLE_TENANT.md.
+# -----------------------------------------------------------------------------
+SETUP_SPACE_NAME=
+SETUP_SPACE_SLUG=
+SETUP_SPACE_CITY=
+SETUP_ADMIN_EMAIL=
+SETUP_ADMIN_PASSWORD=
+SETUP_ADMIN_NAME=
+
# Secrets vault encryption key (required for the /ops Secrets feature).
# 32 bytes of cryptographically random data, hex-encoded (64 hex chars).
# Generate with: openssl rand -hex 32
diff --git a/app/layout.tsx b/app/layout.tsx
index 4a707a1..ba79863 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,13 +1,16 @@
import type { Metadata } from 'next'
import { Geist, Geist_Mono } from 'next/font/google'
import { Toaster } from 'sonner'
+import { tenantConfig } from '@/lib/tenant'
import './globals.css'
const _geist = Geist({ subsets: ["latin"] });
const _geistMono = Geist_Mono({ subsets: ["latin"] });
+const _tenant = tenantConfig()
+
export const metadata: Metadata = {
- title: 'hackerspace.sh — Manage Your Space',
+ title: `${_tenant.siteName} — Manage Your Space`,
description: 'The operating system for hackerspaces: members, tasks, projects, ops, payments, and comms.',
icons: {
icon: [{ url: '/logo.svg', type: 'image/svg+xml' }],
diff --git a/app/signup/page.tsx b/app/signup/page.tsx
index bf8d5f5..c549bc0 100644
--- a/app/signup/page.tsx
+++ b/app/signup/page.tsx
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { createSpace, joinSpace } from '@/lib/auth-actions'
+import { tenantConfig } from '@/lib/tenant'
function generateInviteCode() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
@@ -19,9 +20,13 @@ function generateInviteCode() {
export default function SignupPage() {
const router = useRouter()
+ // Single-tenant instances host one space: there is no "create a space" path,
+ // so signup always means "join THE space". Preselect join and hide the
+ // chooser. createSpace is also refused server-side (defense in depth).
+ const tenant = tenantConfig()
const [step, setStep] = useState<'account' | 'space'>('account')
const [isReturningUser, setIsReturningUser] = useState(false)
- const [mode, setMode] = useState<'create' | 'join' | null>(null)
+ const [mode, setMode] = useState<'create' | 'join' | null>(tenant.singleTenant ? 'join' : null)
const [fullName, setFullName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@@ -158,7 +163,7 @@ export default function SignupPage() {
{'{'}
- hackerspace.sh
+ {tenant.siteName}
{'}'}
@@ -216,6 +221,8 @@ export default function SignupPage() {
/>
+ {!tenant.singleTenant && (
+ <>
What are you doing?
@@ -257,6 +264,8 @@ export default function SignupPage() {
use an invite code
+ >
+ )}
{error && {error}
}
@@ -356,6 +365,15 @@ export default function SignupPage() {
Default channels (general, announcements, ops) will be created automatically.
+ ) : tenant.singleTenant && tenant.openJoin ? (
+
+
+ You're joining {tenant.siteName} .
+
+
+ An administrator may need to approve your membership before you gain full access.
+
+
) : (
diff --git a/lib/actions/forms/submit.ts b/lib/actions/forms/submit.ts
index 622c646..0981c8b 100644
--- a/lib/actions/forms/submit.ts
+++ b/lib/actions/forms/submit.ts
@@ -17,6 +17,7 @@ import {
deriveSubmitterEmail,
} from '@/lib/forms-logic'
import { renderFormEmail, formDedupeKey } from '@/lib/notifications-logic'
+import { appBaseUrl } from '@/lib/tenant'
import {
enqueueNotification,
resolveMemberContact,
@@ -193,7 +194,7 @@ export async function submitForm(input: unknown) {
new Set(((admins ?? []) as Array<{ member_id: string }>).map(a => a.member_id)),
)
if (adminMemberIds.length > 0) {
- const resultsUrl = `${process.env.NEXT_PUBLIC_APP_URL || 'https://hackerspace.sh'}/forms/${form.id}/results`
+ const resultsUrl = `${appBaseUrl()}/forms/${form.id}/results`
const submitterLabel = await deriveSubmitterLabel(admin, form.space_id, {
linkedMemberId,
submitterEmail,
diff --git a/lib/actions/members.ts b/lib/actions/members.ts
index d706abd..e3f3ccc 100644
--- a/lib/actions/members.ts
+++ b/lib/actions/members.ts
@@ -11,6 +11,7 @@ import {
parseInput,
} from '@/lib/auth-helpers'
import { ADMIN_ROLES } from '@/lib/permissions'
+import { appBaseUrl } from '@/lib/tenant'
import {
addMemberSchema,
updateMemberSchema,
@@ -242,7 +243,7 @@ export async function requestEmailChange(input: { email: string }) {
// Origin from trusted server config ONLY — never request headers. A forged
// Host/X-Forwarded-Host could otherwise point the confirmation link at an
// attacker origin and capture the victim's token_hash.
- const origin = process.env.NEXT_PUBLIC_APP_URL || 'https://hackerspace.sh'
+ const origin = appBaseUrl()
const { error } = await supabase.auth.updateUser(
{ email: v.data.email },
diff --git a/lib/actions/stripe.ts b/lib/actions/stripe.ts
index 94afa0f..8e91a93 100644
--- a/lib/actions/stripe.ts
+++ b/lib/actions/stripe.ts
@@ -10,12 +10,13 @@ import { stripeSettingsSchema } from '@/lib/validations'
import { isStripeConfigured, priceIdForTier } from '@/lib/stripe-logic'
import { getStripeConfig, storeStripeSecret, readStripeSecret, type StripeConfig } from '@/lib/stripe/config'
import { getStripe } from '@/lib/stripe/client'
+import { appBaseUrl } from '@/lib/tenant'
async function appOrigin(): Promise {
const h = await headers()
const host = h.get('x-forwarded-host') ?? h.get('host')
const proto = h.get('x-forwarded-proto') ?? 'https'
- return host ? `${proto}://${host}` : 'https://hackerspace.sh'
+ return host ? `${proto}://${host}` : appBaseUrl()
}
// Admin-facing settings view. NEVER returns secret values, only whether they
diff --git a/lib/auth-actions.ts b/lib/auth-actions.ts
index 59b0e72..7164215 100644
--- a/lib/auth-actions.ts
+++ b/lib/auth-actions.ts
@@ -6,6 +6,7 @@ import { redirect } from 'next/navigation'
import { checkRateLimit, sanitizeString, sanitizeSlug } from '@/lib/security'
import { ACTIVE_STATUSES } from '@/lib/permissions'
import { claimInviteUse } from '@/lib/invite-claim'
+import { tenantConfig } from '@/lib/tenant'
function generateInviteCode() {
// Cryptographically random, and wider (8 chars) so the code space is not
@@ -26,6 +27,13 @@ export async function createSpace(formData: {
spaceCity?: string
displayName: string
}) {
+ // Single-tenant instances host exactly one space; the app must not be able to
+ // grow a second tenant. This is the authoritative server-side guard — the
+ // signup UI also hides the "create a space" path, but that is UX only.
+ if (!tenantConfig().allowSpaceCreation) {
+ return { error: 'Space creation is disabled on this instance.' }
+ }
+
// Verify user is authenticated using regular client
const supabase = await createClient()
const { data: { user }, error: userErr } = await supabase.auth.getUser()
@@ -120,32 +128,56 @@ export async function joinSpace(formData: {
// Use admin client for operations
const admin = createAdminClient()
+ const tenant = tenantConfig()
const code = formData.inviteCode.trim().toUpperCase()
- // Try the new multi-code invites table first. Falls back to the legacy
- // spaces.invite_code (a permanent default) if the code isn't found there.
- const { data: inviteRow } = await admin
- .from('space_invites')
- .select('id, space_id, max_uses, uses_count, expires_at, is_enabled, role')
- .eq('code', code)
- .maybeSingle()
- const invite = inviteRow
+ // Single-tenant open-join: when this instance permits joining its one space
+ // without an invite code, resolve that space directly (by configured slug, or
+ // the sole space if no slug is set). require_approval is still honored below,
+ // so new members land 'unverified' when the space demands approval.
+ const openJoin = tenant.singleTenant && tenant.openJoin && code === ''
+ type InviteRow = {
+ id: string; space_id: string; max_uses: number | null
+ uses_count: number; expires_at: string | null; is_enabled: boolean
+ role: 'admin' | 'board' | 'treasurer' | 'member' | 'associate'
+ }
+ let invite: InviteRow | null = null
let spaceId: string | null = null
- if (invite) {
- if (!invite.is_enabled) return { error: 'This invite is disabled.' }
- if (invite.expires_at && new Date(invite.expires_at) < new Date()) return { error: 'This invite has expired.' }
- if (invite.max_uses !== null && invite.uses_count >= invite.max_uses) return { error: 'This invite has reached its use cap.' }
- spaceId = invite.space_id
+
+ if (openJoin) {
+ const { data: target } = tenant.spaceSlug
+ ? await admin.from('spaces').select('id').eq('slug', tenant.spaceSlug).maybeSingle()
+ : await admin.from('spaces').select('id').order('created_at', { ascending: true }).limit(1).maybeSingle()
+ if (!target) {
+ return { error: 'This instance is not set up yet. Ask an administrator to finish setup.' }
+ }
+ spaceId = target.id
} else {
- const { data: legacy } = await admin
- .from('spaces')
- .select('id')
- .eq('invite_code', code)
+ // Try the new multi-code invites table first. Falls back to the legacy
+ // spaces.invite_code (a permanent default) if the code isn't found there.
+ const { data: inviteRow } = await admin
+ .from('space_invites')
+ .select('id, space_id, max_uses, uses_count, expires_at, is_enabled, role')
+ .eq('code', code)
.maybeSingle()
- if (!legacy) return { error: 'Invalid invite code' }
- spaceId = legacy.id
+ invite = inviteRow
+
+ if (invite) {
+ if (!invite.is_enabled) return { error: 'This invite is disabled.' }
+ if (invite.expires_at && new Date(invite.expires_at) < new Date()) return { error: 'This invite has expired.' }
+ if (invite.max_uses !== null && invite.uses_count >= invite.max_uses) return { error: 'This invite has reached its use cap.' }
+ spaceId = invite.space_id
+ } else {
+ const { data: legacy } = await admin
+ .from('spaces')
+ .select('id')
+ .eq('invite_code', code)
+ .maybeSingle()
+ if (!legacy) return { error: 'Invalid invite code' }
+ spaceId = legacy.id
+ }
}
const { data: space, error: lookupErr } = await admin
diff --git a/lib/notifications/enqueue.ts b/lib/notifications/enqueue.ts
index a849e79..4b64348 100644
--- a/lib/notifications/enqueue.ts
+++ b/lib/notifications/enqueue.ts
@@ -12,6 +12,7 @@
import type { createAdminClient } from '@/lib/supabase/admin'
import { captureException } from '@/lib/observability/capture'
+import { appBaseUrl } from '@/lib/tenant'
type AdminClient = ReturnType
@@ -122,9 +123,9 @@ export async function getSpaceName(admin: AdminClient, spaceId: string): Promise
// Build the member portal URL the way every enqueue site does. Header host
// takes precedence (lets a request to a non-canonical host link back to
-// itself); otherwise falls back to NEXT_PUBLIC_APP_URL and finally the prod
-// default. Same shape as the existing dues path.
+// itself); otherwise falls back to the configured base URL (appBaseUrl, which
+// resolves NEXT_PUBLIC_APP_URL). Same shape as the existing dues path.
export function buildManageUrl(host?: string | null, proto?: string | null): string {
if (host) return `${proto || 'https'}://${host}/me`
- return `${process.env.NEXT_PUBLIC_APP_URL || 'https://hackerspace.sh'}/me`
+ return `${appBaseUrl()}/me`
}
diff --git a/lib/tenant.ts b/lib/tenant.ts
new file mode 100644
index 0000000..8fee2d3
--- /dev/null
+++ b/lib/tenant.ts
@@ -0,0 +1,141 @@
+// -----------------------------------------------------------------------------
+// Tenant / deployment configuration — the single source of truth for whether
+// this installation runs as a multi-tenant platform (the hackerspace.sh model,
+// one deployment hosting many spaces) or a single-tenant instance (one org runs
+// its own private deployment with exactly one space).
+//
+// The whole app is space-scoped either way; single-tenant mode does not change
+// the data model. It only (a) forbids creating a second space, (b) turns the
+// signup flow into "join THE space" instead of "create or join", (c) optionally
+// opens join without an invite code, (d) hides the hackerspace.sh marketing
+// shell, and (e) lets a deployer white-label the brand name and base URL.
+//
+// Pattern mirrors lib/auth-config.ts: a PURE resolver (unit-tested, no env) plus
+// a thin env-reading wrapper. Flags that the browser needs (the signup page is a
+// client component) are NEXT_PUBLIC_ so Next.js inlines them into the client
+// bundle — hence the literal `process.env.NEXT_PUBLIC_*` member expressions.
+//
+// SECURITY: every NEXT_PUBLIC_ flag is visible to and forgeable by the browser.
+// The server MUST re-enforce the same rules (see lib/auth-actions.ts). Treat the
+// client-side use of this config as UX only, never as an authorization boundary.
+// -----------------------------------------------------------------------------
+
+export type TenantConfig = {
+ /** True when this deployment hosts exactly one space and behaves as a
+ * dedicated instance rather than the multi-tenant platform. */
+ singleTenant: boolean
+ /** Brand name shown throughout the UI (logo wordmark, page titles). */
+ siteName: string
+ /** Canonical public base URL of this deployment, no trailing slash. Used for
+ * absolute links in emails, form-result links, and OAuth redirects. */
+ appUrl: string
+ /** In single-tenant mode, the slug of the one space new signups join. Null
+ * falls back to "the only space in the database" at join time. */
+ spaceSlug: string | null
+ /** Single-tenant only: allow signups to join without an invite code (still
+ * subject to the space's require_approval gate). Ignored in multi-tenant. */
+ openJoin: boolean
+ /** Whether the "create a space" flow (signup UI + createSpace action) is
+ * available. False in single-tenant mode so the instance cannot grow a
+ * second tenant through the app. */
+ allowSpaceCreation: boolean
+ /** Whether the public marketing/landing shell (`/`, the /resources subsite)
+ * is served. Defaults off in single-tenant mode. */
+ showMarketing: boolean
+}
+
+export type TenantEnv = {
+ singleTenant?: string
+ siteName?: string
+ appUrl?: string
+ spaceSlug?: string
+ openJoin?: string
+ showMarketing?: string
+}
+
+const DEFAULT_SITE_NAME = 'hackerspace.sh'
+const DEFAULT_APP_URL = 'http://localhost:3000'
+
+function isTrue(v: string | undefined): boolean {
+ return v === 'true'
+}
+
+/** Strip a trailing slash so callers can safely template `${appUrl}/path`. */
+function normalizeUrl(v: string | undefined, fallback: string): string {
+ const raw = v?.trim()
+ if (!raw) return fallback
+ return raw.replace(/\/+$/, '')
+}
+
+/**
+ * Pure core: resolve the tenant configuration from raw flag strings. No
+ * process.env access, so it is trivially unit-testable. All defaulting and
+ * precedence lives here.
+ */
+export function resolveTenantConfig(env: TenantEnv): TenantConfig {
+ const singleTenant = isTrue(env.singleTenant)
+ const slug = env.spaceSlug?.trim().toLowerCase()
+
+ return {
+ singleTenant,
+ siteName: env.siteName?.trim() || DEFAULT_SITE_NAME,
+ appUrl: normalizeUrl(env.appUrl, DEFAULT_APP_URL),
+ spaceSlug: singleTenant ? (slug || null) : null,
+ // Open join only means anything in single-tenant mode. In multi-tenant the
+ // invite code is the whole join model and is always required.
+ openJoin: singleTenant && isTrue(env.openJoin),
+ // Second-space creation is disabled in single-tenant mode. There is no
+ // opt-out flag: the instance is single-tenant by definition.
+ allowSpaceCreation: !singleTenant,
+ // Marketing shell defaults to on for the platform, off for an instance,
+ // but a single-tenant deployer can re-enable it explicitly.
+ showMarketing: env.showMarketing !== undefined
+ ? isTrue(env.showMarketing)
+ : !singleTenant,
+ }
+}
+
+/**
+ * Env-reading wrapper. The `process.env.NEXT_PUBLIC_*` reads are written as
+ * literals so Next.js statically inlines them into the client bundle. Safe to
+ * call from both server and client components.
+ */
+export function tenantConfig(): TenantConfig {
+ return resolveTenantConfig({
+ singleTenant: process.env.NEXT_PUBLIC_SINGLE_TENANT,
+ siteName: process.env.NEXT_PUBLIC_SITE_NAME,
+ appUrl: process.env.NEXT_PUBLIC_APP_URL,
+ spaceSlug: process.env.NEXT_PUBLIC_SINGLE_TENANT_SPACE_SLUG,
+ openJoin: process.env.NEXT_PUBLIC_SINGLE_TENANT_OPEN_JOIN,
+ showMarketing: process.env.NEXT_PUBLIC_SHOW_MARKETING,
+ })
+}
+
+/**
+ * Canonical base URL for absolute links (emails, form results, self-serve
+ * portal links). Replaces the scattered `NEXT_PUBLIC_APP_URL || 'https://hackerspace.sh'`
+ * fallbacks so a single-tenant deploy never leaks the platform domain.
+ */
+export function appBaseUrl(): string {
+ return tenantConfig().appUrl
+}
+
+/** The set of public marketing paths gated by `showMarketing`. Kept in sync
+ * with the /resources subsite routes and the landing page. */
+export const MARKETING_PATHS = [
+ '/',
+ '/resources',
+ '/zine',
+ '/governance',
+ '/space-after-dark',
+ '/proposal-duel',
+ '/atlas',
+ '/atlas.html',
+] as const
+
+/** True when `pathname` is part of the marketing shell (exact or subpath). */
+export function isMarketingPath(pathname: string): boolean {
+ return MARKETING_PATHS.some(
+ p => pathname === p || (p !== '/' && pathname.startsWith(p + '/')),
+ )
+}
diff --git a/package.json b/package.json
index 6f2695b..74ae593 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"dev": "next dev",
"build": "next build",
"start": "next start",
+ "setup": "node scripts/setup.mjs",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"test": "vitest",
diff --git a/proxy.ts b/proxy.ts
index fcfc9cc..3b7e53e 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -1,6 +1,7 @@
import { updateSession } from '@/lib/supabase/proxy'
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
+import { tenantConfig, isMarketingPath } from '@/lib/tenant'
/** Shape `@supabase/ssr` passes to `setAll` (typing lost to the ssr/js version drift). */
type CookiesToSet = { name: string; value: string; options: CookieOptions }[]
@@ -64,6 +65,15 @@ const PUBLIC_ROUTES = [
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
+ // Single-tenant instances hide the hackerspace.sh marketing shell (the
+ // landing page and the /resources subsite). Those paths are otherwise public;
+ // when marketing is disabled we send them to the app's login entry instead of
+ // rendering platform content. Authenticated users get bounced on to the app
+ // by /login itself.
+ if (!tenantConfig().showMarketing && isMarketingPath(pathname)) {
+ return NextResponse.redirect(new URL('/login', request.url))
+ }
+
// Always update session to refresh cookies
const response = await updateSession(request)
diff --git a/scripts/schema.sql b/scripts/schema.sql
index f9139ff..01aca3a 100644
--- a/scripts/schema.sql
+++ b/scripts/schema.sql
@@ -2978,3 +2978,17 @@ DROP TRIGGER IF EXISTS trg_dues_payment_methods_touch ON public.dues_payment_met
CREATE TRIGGER trg_dues_payment_methods_touch
BEFORE UPDATE ON public.dues_payment_methods
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
+
+
+-- -----------------------------------------------------------------------------
+-- MIGRATION TRACKING
+-- -----------------------------------------------------------------------------
+-- The deploy script (deploy/deploy.sh) applies each numbered scripts/0NN_*.sql
+-- exactly once and records the filename here. Defined in the canonical schema so
+-- a fresh instance is fully reproducible from this file alone (previously this
+-- table was created ad hoc on the production host and existed nowhere in-repo).
+-- Not a tenant table: it holds no member data and needs no RLS.
+CREATE TABLE IF NOT EXISTS public._migrations_applied (
+ filename text PRIMARY KEY,
+ applied_at timestamptz NOT NULL DEFAULT now()
+);