Docs / Admin panel / Feature flags & settings
Feature flags & settings
The /admin/settings page has two tabs: feature flags (per-team and global) and a small set of global app settings. Both are admin-managed configuration stored in the database, wired into the panel's UI but, in v1, not yet read by any runtime code path, they are the config surface you build on top of, not a live gate out of the box.
Feature flags
Flags live in the feature_flags table (lib/db/schema/feature-flags.ts), managed through lib/dal/admin-flags.ts. A flag has a key (mono, [a-z0-9-]), a display name, a description, an enabled boolean, and a scope.
The override model is one table doing double duty. A row with team_id IS NULL is the global default for its key; a row with a team_id overrides that default for one team.
A single global row per key is enforced in app code, not the DB
The unique index is on (key, team_id), and Postgres allows multiple NULLs in a unique index, so the database would happily accept two global rows for the same key. getGlobalFlagByKey and the create path enforce "one global row per key" in code, keep new flag logic going through the DAL so that invariant holds.
scope only means anything on the global row and controls whether the UI offers per-team overrides at all. A global-scoped flag is deliberately override-proof (think a site-wide kill switch); setFlagOverride returns null rather than creating an override for one, so you never get a silent override row that no resolver reads. Deleting a flag (deleteFlagAndOverrides) removes the global row and every per-team override sharing its key in one action.
Wiring a flag into behavior is the buyer's step: read getGlobalFlagByKey(key) (and, for per-team flags, the team's override row) wherever you want to gate a feature, then flip it from the admin panel.
Global settings
The Global settings tab is a single row in app_settings (lib/db/schema/app-settings.ts), read and upserted through lib/dal/admin-settings.ts. Typed columns, not a key/value store, since the set is small and known: maintenanceMode, pauseSignups, defaultTeamRole, pauseOutgoingEmail, and allowTeamCreation. The migration seeds the row, so getAppSettings effectively always finds it.
pauseOutgoingEmail and allowTeamCreation are enforced: sendEmail (lib/email.ts) checks the flag before calling Resend, and POST /api/teams checks it before creating a team. The other three are stored and editable but not yet enforced, they're the hooks for behavior you add:
maintenanceModeisn't checked inproxy.tson purpose, that file only does an optimistic session-cookie check and deliberately avoids a DB round-trip on every navigation (see its own comment). Gate on this flag from a layout's server component instead, where a DB read already happens.pauseSignupswould need adatabaseHooks.user.create.beforehook inlib/auth.tsthat returnsfalseto block creation, verify the exact behavior againstnode_modules/@better-auth/corebefore wiring it, the "Better Auth version gotcha" in AGENTS.md applies here.defaultTeamRolehas no current call site: team creation always makes the creatorowner(app/api/teams/route.ts), and invite acceptance uses the role chosen at invite time (app/api/invites/[token]/accept/route.ts), not a global default. It's a hook for a join flow you haven't built yet (e.g. domain-based auto-join).
The admin panel marks these three "Not enforced yet" so it doesn't imply they're live.
Note defaultTeamRole is a per-team role (member by default), never owner, owner is only ever assigned by team-creation logic itself, never handed to a sign-up.