Files
Moheeb Zara 7b8d2ae167 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.
2026-08-05 13:02:18 -07:00

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.

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:

    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):

    export type TenantEnv = {
      // ...existing fields...
      showRecruitment?: string
    }
    
  3. 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
    }
    
  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.

    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:

    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 for the marketing behavior.