feat(tenant): single-tenant mode core (config SSOT + server/UI guards + branding)
Add lib/tenant.ts as the single source of truth for multi- vs single-tenant deployment. Gate space creation (server + signup UI), add open-join to the one space, hide the marketing shell, and route brand name + base URL through config. Also commit the _migrations_applied table into the canonical schema so a fresh instance is reproducible from schema.sql alone. - lib/tenant.ts: resolveTenantConfig (pure) + tenantConfig() + appBaseUrl() - lib/auth-actions.ts: createSpace refused when !allowSpaceCreation; joinSpace open-join resolves the configured/sole space with no invite code - proxy.ts: marketing paths redirect to /login when showMarketing is off - app/signup/page.tsx: single-tenant hides create/join chooser, brand=siteName - app/layout.tsx + 4 link builders: brand/base-URL via config (no hackerspace.sh literal) - scripts/schema.sql: public._migrations_applied tracking table - .env.example: documented single-tenant + first-admin bootstrap vars - package.json: pnpm setup
This commit is contained in:
+50
-1
@@ -14,9 +14,58 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOi...replace-with-anon-key
|
|||||||
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...replace-with-service-role-key
|
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOi...replace-with-service-role-key
|
||||||
|
|
||||||
# App URL (required)
|
# 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
|
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).
|
# Secrets vault encryption key (required for the /ops Secrets feature).
|
||||||
# 32 bytes of cryptographically random data, hex-encoded (64 hex chars).
|
# 32 bytes of cryptographically random data, hex-encoded (64 hex chars).
|
||||||
# Generate with: openssl rand -hex 32
|
# Generate with: openssl rand -hex 32
|
||||||
|
|||||||
+4
-1
@@ -1,13 +1,16 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { Geist, Geist_Mono } from 'next/font/google'
|
import { Geist, Geist_Mono } from 'next/font/google'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
|
import { tenantConfig } from '@/lib/tenant'
|
||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
|
||||||
const _geist = Geist({ subsets: ["latin"] });
|
const _geist = Geist({ subsets: ["latin"] });
|
||||||
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
||||||
|
|
||||||
|
const _tenant = tenantConfig()
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
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.',
|
description: 'The operating system for hackerspaces: members, tasks, projects, ops, payments, and comms.',
|
||||||
icons: {
|
icons: {
|
||||||
icon: [{ url: '/logo.svg', type: 'image/svg+xml' }],
|
icon: [{ url: '/logo.svg', type: 'image/svg+xml' }],
|
||||||
|
|||||||
+20
-2
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { createClient } from '@/lib/supabase/client'
|
import { createClient } from '@/lib/supabase/client'
|
||||||
import { createSpace, joinSpace } from '@/lib/auth-actions'
|
import { createSpace, joinSpace } from '@/lib/auth-actions'
|
||||||
|
import { tenantConfig } from '@/lib/tenant'
|
||||||
|
|
||||||
function generateInviteCode() {
|
function generateInviteCode() {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
@@ -19,9 +20,13 @@ function generateInviteCode() {
|
|||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
const router = useRouter()
|
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 [step, setStep] = useState<'account' | 'space'>('account')
|
||||||
const [isReturningUser, setIsReturningUser] = useState(false)
|
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 [fullName, setFullName] = useState('')
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
@@ -158,7 +163,7 @@ export default function SignupPage() {
|
|||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<span className="font-mono text-2xl font-bold">
|
<span className="font-mono text-2xl font-bold">
|
||||||
<span className="text-[#d4f53c]">{'{'}</span>
|
<span className="text-[#d4f53c]">{'{'}</span>
|
||||||
<span className="text-white">hackerspace.sh</span>
|
<span className="text-white">{tenant.siteName}</span>
|
||||||
<span className="text-[#d4f53c]">{'}'}</span>
|
<span className="text-[#d4f53c]">{'}'}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,6 +221,8 @@ export default function SignupPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!tenant.singleTenant && (
|
||||||
|
<>
|
||||||
<p className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase mb-3">
|
<p className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase mb-3">
|
||||||
What are you doing?
|
What are you doing?
|
||||||
</p>
|
</p>
|
||||||
@@ -257,6 +264,8 @@ export default function SignupPage() {
|
|||||||
<p className="font-mono text-xs text-zinc-500">use an invite code</p>
|
<p className="font-mono text-xs text-zinc-500">use an invite code</p>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <p className="font-mono text-xs text-red-400 mb-4">{error}</p>}
|
{error && <p className="font-mono text-xs text-red-400 mb-4">{error}</p>}
|
||||||
|
|
||||||
@@ -356,6 +365,15 @@ export default function SignupPage() {
|
|||||||
<p className="font-mono text-[10px] text-zinc-500">Default channels (general, announcements, ops) will be created automatically.</p>
|
<p className="font-mono text-[10px] text-zinc-500">Default channels (general, announcements, ops) will be created automatically.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : tenant.singleTenant && tenant.openJoin ? (
|
||||||
|
<div className="mb-6 bg-[#232323] border border-[#333] rounded p-4">
|
||||||
|
<p className="font-mono text-[10px] text-zinc-500 mb-1">
|
||||||
|
You're joining <span className="text-white">{tenant.siteName}</span>.
|
||||||
|
</p>
|
||||||
|
<p className="font-mono text-[10px] text-zinc-500">
|
||||||
|
An administrator may need to approve your membership before you gain full access.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
|
<label className="font-mono text-[10px] tracking-widest text-zinc-500 uppercase block mb-1">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
deriveSubmitterEmail,
|
deriveSubmitterEmail,
|
||||||
} from '@/lib/forms-logic'
|
} from '@/lib/forms-logic'
|
||||||
import { renderFormEmail, formDedupeKey } from '@/lib/notifications-logic'
|
import { renderFormEmail, formDedupeKey } from '@/lib/notifications-logic'
|
||||||
|
import { appBaseUrl } from '@/lib/tenant'
|
||||||
import {
|
import {
|
||||||
enqueueNotification,
|
enqueueNotification,
|
||||||
resolveMemberContact,
|
resolveMemberContact,
|
||||||
@@ -193,7 +194,7 @@ export async function submitForm(input: unknown) {
|
|||||||
new Set(((admins ?? []) as Array<{ member_id: string }>).map(a => a.member_id)),
|
new Set(((admins ?? []) as Array<{ member_id: string }>).map(a => a.member_id)),
|
||||||
)
|
)
|
||||||
if (adminMemberIds.length > 0) {
|
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, {
|
const submitterLabel = await deriveSubmitterLabel(admin, form.space_id, {
|
||||||
linkedMemberId,
|
linkedMemberId,
|
||||||
submitterEmail,
|
submitterEmail,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
parseInput,
|
parseInput,
|
||||||
} from '@/lib/auth-helpers'
|
} from '@/lib/auth-helpers'
|
||||||
import { ADMIN_ROLES } from '@/lib/permissions'
|
import { ADMIN_ROLES } from '@/lib/permissions'
|
||||||
|
import { appBaseUrl } from '@/lib/tenant'
|
||||||
import {
|
import {
|
||||||
addMemberSchema,
|
addMemberSchema,
|
||||||
updateMemberSchema,
|
updateMemberSchema,
|
||||||
@@ -242,7 +243,7 @@ export async function requestEmailChange(input: { email: string }) {
|
|||||||
// Origin from trusted server config ONLY — never request headers. A forged
|
// Origin from trusted server config ONLY — never request headers. A forged
|
||||||
// Host/X-Forwarded-Host could otherwise point the confirmation link at an
|
// Host/X-Forwarded-Host could otherwise point the confirmation link at an
|
||||||
// attacker origin and capture the victim's token_hash.
|
// 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(
|
const { error } = await supabase.auth.updateUser(
|
||||||
{ email: v.data.email },
|
{ email: v.data.email },
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import { stripeSettingsSchema } from '@/lib/validations'
|
|||||||
import { isStripeConfigured, priceIdForTier } from '@/lib/stripe-logic'
|
import { isStripeConfigured, priceIdForTier } from '@/lib/stripe-logic'
|
||||||
import { getStripeConfig, storeStripeSecret, readStripeSecret, type StripeConfig } from '@/lib/stripe/config'
|
import { getStripeConfig, storeStripeSecret, readStripeSecret, type StripeConfig } from '@/lib/stripe/config'
|
||||||
import { getStripe } from '@/lib/stripe/client'
|
import { getStripe } from '@/lib/stripe/client'
|
||||||
|
import { appBaseUrl } from '@/lib/tenant'
|
||||||
|
|
||||||
async function appOrigin(): Promise<string> {
|
async function appOrigin(): Promise<string> {
|
||||||
const h = await headers()
|
const h = await headers()
|
||||||
const host = h.get('x-forwarded-host') ?? h.get('host')
|
const host = h.get('x-forwarded-host') ?? h.get('host')
|
||||||
const proto = h.get('x-forwarded-proto') ?? 'https'
|
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
|
// Admin-facing settings view. NEVER returns secret values, only whether they
|
||||||
|
|||||||
+34
-2
@@ -6,6 +6,7 @@ import { redirect } from 'next/navigation'
|
|||||||
import { checkRateLimit, sanitizeString, sanitizeSlug } from '@/lib/security'
|
import { checkRateLimit, sanitizeString, sanitizeSlug } from '@/lib/security'
|
||||||
import { ACTIVE_STATUSES } from '@/lib/permissions'
|
import { ACTIVE_STATUSES } from '@/lib/permissions'
|
||||||
import { claimInviteUse } from '@/lib/invite-claim'
|
import { claimInviteUse } from '@/lib/invite-claim'
|
||||||
|
import { tenantConfig } from '@/lib/tenant'
|
||||||
|
|
||||||
function generateInviteCode() {
|
function generateInviteCode() {
|
||||||
// Cryptographically random, and wider (8 chars) so the code space is not
|
// Cryptographically random, and wider (8 chars) so the code space is not
|
||||||
@@ -26,6 +27,13 @@ export async function createSpace(formData: {
|
|||||||
spaceCity?: string
|
spaceCity?: string
|
||||||
displayName: 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
|
// Verify user is authenticated using regular client
|
||||||
const supabase = await createClient()
|
const supabase = await createClient()
|
||||||
const { data: { user }, error: userErr } = await supabase.auth.getUser()
|
const { data: { user }, error: userErr } = await supabase.auth.getUser()
|
||||||
@@ -120,9 +128,33 @@ export async function joinSpace(formData: {
|
|||||||
|
|
||||||
// Use admin client for operations
|
// Use admin client for operations
|
||||||
const admin = createAdminClient()
|
const admin = createAdminClient()
|
||||||
|
const tenant = tenantConfig()
|
||||||
|
|
||||||
const code = formData.inviteCode.trim().toUpperCase()
|
const code = formData.inviteCode.trim().toUpperCase()
|
||||||
|
|
||||||
|
// 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 (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 {
|
||||||
// Try the new multi-code invites table first. Falls back to the legacy
|
// 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.
|
// spaces.invite_code (a permanent default) if the code isn't found there.
|
||||||
const { data: inviteRow } = await admin
|
const { data: inviteRow } = await admin
|
||||||
@@ -130,9 +162,8 @@ export async function joinSpace(formData: {
|
|||||||
.select('id, space_id, max_uses, uses_count, expires_at, is_enabled, role')
|
.select('id, space_id, max_uses, uses_count, expires_at, is_enabled, role')
|
||||||
.eq('code', code)
|
.eq('code', code)
|
||||||
.maybeSingle()
|
.maybeSingle()
|
||||||
const invite = inviteRow
|
invite = inviteRow
|
||||||
|
|
||||||
let spaceId: string | null = null
|
|
||||||
if (invite) {
|
if (invite) {
|
||||||
if (!invite.is_enabled) return { error: 'This invite is disabled.' }
|
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.expires_at && new Date(invite.expires_at) < new Date()) return { error: 'This invite has expired.' }
|
||||||
@@ -147,6 +178,7 @@ export async function joinSpace(formData: {
|
|||||||
if (!legacy) return { error: 'Invalid invite code' }
|
if (!legacy) return { error: 'Invalid invite code' }
|
||||||
spaceId = legacy.id
|
spaceId = legacy.id
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { data: space, error: lookupErr } = await admin
|
const { data: space, error: lookupErr } = await admin
|
||||||
.from('spaces')
|
.from('spaces')
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
|
|
||||||
import type { createAdminClient } from '@/lib/supabase/admin'
|
import type { createAdminClient } from '@/lib/supabase/admin'
|
||||||
import { captureException } from '@/lib/observability/capture'
|
import { captureException } from '@/lib/observability/capture'
|
||||||
|
import { appBaseUrl } from '@/lib/tenant'
|
||||||
|
|
||||||
type AdminClient = ReturnType<typeof createAdminClient>
|
type AdminClient = ReturnType<typeof createAdminClient>
|
||||||
|
|
||||||
@@ -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
|
// 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
|
// 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
|
// itself); otherwise falls back to the configured base URL (appBaseUrl, which
|
||||||
// default. Same shape as the existing dues path.
|
// resolves NEXT_PUBLIC_APP_URL). Same shape as the existing dues path.
|
||||||
export function buildManageUrl(host?: string | null, proto?: string | null): string {
|
export function buildManageUrl(host?: string | null, proto?: string | null): string {
|
||||||
if (host) return `${proto || 'https'}://${host}/me`
|
if (host) return `${proto || 'https'}://${host}/me`
|
||||||
return `${process.env.NEXT_PUBLIC_APP_URL || 'https://hackerspace.sh'}/me`
|
return `${appBaseUrl()}/me`
|
||||||
}
|
}
|
||||||
|
|||||||
+141
@@ -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 + '/')),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
|
"setup": "node scripts/setup.mjs",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { updateSession } from '@/lib/supabase/proxy'
|
import { updateSession } from '@/lib/supabase/proxy'
|
||||||
import { createServerClient, type CookieOptions } from '@supabase/ssr'
|
import { createServerClient, type CookieOptions } from '@supabase/ssr'
|
||||||
import { NextResponse, type NextRequest } from 'next/server'
|
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). */
|
/** Shape `@supabase/ssr` passes to `setAll` (typing lost to the ssr/js version drift). */
|
||||||
type CookiesToSet = { name: string; value: string; options: CookieOptions }[]
|
type CookiesToSet = { name: string; value: string; options: CookieOptions }[]
|
||||||
@@ -64,6 +65,15 @@ const PUBLIC_ROUTES = [
|
|||||||
export async function proxy(request: NextRequest) {
|
export async function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl
|
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
|
// Always update session to refresh cookies
|
||||||
const response = await updateSession(request)
|
const response = await updateSession(request)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
CREATE TRIGGER trg_dues_payment_methods_touch
|
||||||
BEFORE UPDATE ON public.dues_payment_methods
|
BEFORE UPDATE ON public.dues_payment_methods
|
||||||
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
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()
|
||||||
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user