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.
7.8 KiB
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). 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):
TenantConfigis the typed, resolved config the rest of the app consumes.resolveTenantConfig(env)is a pure function: raw flag strings in, resolved config out, noprocess.envaccess. All defaulting and precedence lives here, so it is trivially unit-testable.tenantConfig()is a thin wrapper that reads theprocess.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:
-
Add a field to
TenantConfig. Give it a resolved type and a doc comment:export type TenantConfig = { // ...existing fields... /** Whether the public recruitment page is served. */ showRecruitment: boolean } -
Add the raw input to
TenantEnv(all inputs are optional strings, because that is whatprocess.envgives you):export type TenantEnv = { // ...existing fields... showRecruitment?: string } -
Add the rule to
resolveTenantConfig. This is where defaulting and precedence live. Keep it pure:return { // ...existing fields... showRecruitment: env.showRecruitment !== undefined ? isTrue(env.showRecruitment) : true, // default on } -
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 noprocess.env, the test passes plain objects in.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) }) -
Read the env var in
tenantConfig(). Use a literalprocess.env.NEXT_PUBLIC_*member expression so Next.js can inline it:return resolveTenantConfig({ // ...existing reads... showRecruitment: process.env.NEXT_PUBLIC_SHOW_RECRUITMENT, }) -
Consume it. In a server or client component,
const { showRecruitment } = tenantConfig()and branch on it. Document the new variable in.env.exampleand in the env table inSINGLE_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:createSpacere-checksallowSpaceCreation,joinSpacere-checks the open-join rules, andproxy.tsre-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 aNEXT_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.tsxis the landing page (/), withapp/(landing)/layout.tsxandapp/(landing)/landing.cssfor its shell and styles.app/(resources)/holds the resources subsite (/resources,/zine,/governance,/space-after-dark,/proposal-duel,/atlas), with its ownlayout.tsxandresources.css.components/landing/andcomponents/brand-mark.tsxare 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 for the marketing behavior.