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:
Moheeb Zara
2026-08-05 12:43:11 -07:00
parent 85f3655e6f
commit 646d2e542c
12 changed files with 301 additions and 29 deletions
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -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 },
+2 -1
View File
@@ -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<string> {
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