ADR-008: Site Settings Architecture
ADR-008: Site Settings Architecture
Status
Accepted
Context
Toast needs site-level settings for configuration, branding, SEO, and locale. The approach to storing these settings has long-term implications for:
- Security - Some settings are public (branding, locale), others are sensitive (API keys, Stripe config)
- Performance - Public settings are read on every page load and should be edge-cacheable
- Type safety - Settings should be validated at the database and application level
- Extensibility - New settings will be added in Phase 3 (Stripe) and Phase 4 (email)
The Ghost Problem
Ghost uses a key-value settings table (key, value, type, group) that became a long-standing source of pain:
- No type safety - everything is a string, requires serialization/deserialization
- No database-level validation - invalid data can be written
- Hard to query - can't do
WHERE accent_color = '#ff5500' - AI-unfriendly - opaque key-value pairs vs clear schema
- Security risk - no structural separation between public and private settings
Requirements
From the PRD Phase 1 scope:
- Site identity: name, description
- Branding: logo, icon, cover image, accent color
- Locale: language, timezone
- SEO/meta: meta title, meta description, og image
Future phases will add:
- Phase 2: Navigation configuration
- Phase 3: Stripe/membership configuration (sensitive keys)
- Phase 4: Email sender configuration (provider credentials)
Research: What Other Projects Store
We analyzed Cal.com and Directus to understand what settings they store and how they categorize them.
Cal.com Settings (Booking Platform)
| Category | Fields | Visibility |
|---|---|---|
| Identity | name, slug, bio | Public |
| Visual Branding | logo, banner, brandColor, darkBrandColor, theme, hideBranding | Public |
| Locale/Time | timeZone, weekStart, timeFormat | Public |
| SEO | allowSEOIndexing | Public |
| Visibility Controls | isPrivate, hideTeamProfileLink | Admin |
| Feature Locks | lockEventTypeCreation, isAdminAPIEnabled | Admin |
| Booking Limits | bookingLimits, includeManagedEventsInLimits | Admin |
| Notification Controls | 9 different disableAttendee*Email flags | Admin |
| Domain/Verification | orgAutoAcceptEmail, isOrganizationVerified | Admin |
| Billing | subscriptionId, paymentId, orgSeats (in metadata JSON) | Admin |
Directus Settings (Headless CMS)
| Category | Fields | Visibility |
|---|---|---|
| Project Identity | project_name, project_descriptor, project_url | Public |
| Visual Branding | project_logo, project_color | Public |
| Login Page Assets | public_foreground, public_background, public_favicon | Public |
| Theming | default_appearance, theme_light_overrides, custom_css | Public |
| Localization | default_language | Public |
| Security | auth_login_attempts, auth_password_policy | Admin |
| Storage | storage_asset_transform, storage_asset_presets | Admin |
| AI Integration | ai_openai_api_key (masked), ai_anthropic_api_key | Admin (sensitive) |
Key Insight: Locale/Timezone Are Public
Both Cal.com and Directus expose locale and timezone publicly:
- Cal.com shows timezone on booking pages for date formatting
- Directus returns
default_languagein the unauthenticated/server/infoendpoint
For Toast, themes need timezone for date formatting, and locale affects RSS feeds and meta tags. These belong in public settings.
What's Actually Private?
Private/admin-only settings fall into clear categories:
- Security settings (login attempts, password policy)
- API keys (Stripe, email providers, AI services)
- Feature flags (lock features, admin API access)
- Billing (subscription IDs, payment info)
For Toast Phase 1, all settings are public. Private settings arrive in Phase 3 (Stripe) and Phase 4 (email).
Options Considered
1. Key-Value Store (Ghost Pattern)
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT,
type TEXT,
group TEXT
);Pros:
- Flexible - add new settings without migrations
- Simple schema
Cons:
- No type safety at DB level
- Requires serialization/deserialization
- Can't validate or query effectively
- Security: public and private mixed together
- Every project we researched has moved away from this pattern
2. Typed Columns on Sites Table
ALTER TABLE sites
ADD COLUMN description TEXT;
ALTER TABLE sites
ADD COLUMN logo TEXT;
-- ... 10+ more columnsPros:
- Simple - one table
- Type safe
Cons:
- Sites table grows unbounded
- Mixes tenant identity with configuration
- No separation between public/private settings
- Every site query returns all settings
3. Single Settings Table (1:1 with Sites)
CREATE TABLE site_settings (
site_id UUID PRIMARY KEY REFERENCES sites (id),
description TEXT,
logo TEXT,
timezone TEXT,
stripe_secret_key TEXT,
-- all settings in one table
);Pros:
- Clean separation from sites table
- Type safe
- Single join for all settings
Cons:
- No security boundary - public branding mixed with secret Stripe keys
- Can't cache public settings separately from private
- Table grows as features are added
4. Separate Public/Private Tables
CREATE TABLE site_settings_public (
site_id UUID PRIMARY KEY REFERENCES sites (id),
description TEXT,
logo TEXT,
timezone TEXT,
locale VARCHAR(10),
accent_color VARCHAR(7),
meta_title VARCHAR(300),
-- everything themes/public API need
);
CREATE TABLE site_settings_private (
site_id UUID PRIMARY KEY REFERENCES sites (id),
-- Phase 3: stripe_account_id, stripe_secret_key
-- Phase 4: email_from_address, email_provider_config
-- admin-only, may contain secrets
);Pros:
- Clear security boundary
- Different caching strategies per table
- Public settings exposed via Content API without risk
- Each table focused on a specific concern
- Type safe with database-level constraints
Cons:
- Two tables instead of one
- Two joins to get all settings (but usually you need only one)
Decision
Use separate public/private tables (Option 4):
site_settings_public- Everything themes and public API need (identity, branding, locale, SEO)site_settings_private- Sensitive configuration (empty for Phase 1, ready for Stripe/email later)
Table Naming
We chose site_settings_public / site_settings_private over alternatives like site_branding because:
- "Branding" is too narrow - Public settings include identity, locale, and SEO, not just visual branding
- Clear 1:1 relationship - The
site_prefix makes it obvious these belong tosites - Explicit visibility -
public/privatesays exactly what the security boundary is - Discoverable - In a table list, they appear together:
sites,site_settings_public,site_settings_private
Rationale
- Security by structure - You cannot accidentally expose Stripe keys if they're in a different table
- Caching alignment - Public settings can be edge-cached; private settings should never be cached
- Access patterns differ - Every page load needs public settings; only admin needs private
- Future-proof - When Phase 3 adds Stripe and Phase 4 adds email, sensitive config has a clear home
- Industry consensus - Cal.com, Dub.co, Directus, and Payload all use typed fields, not KV stores
Research Summary
| Project | Pattern | KV Store? | Observation |
|---|---|---|---|
| Cal.com | Dedicated 1:1 settings tables | No | OrganizationSettings (~20 typed columns), locale on main model |
| Directus | Single-row typed columns | No | ~50 columns, locale/branding exposed publicly via /server/info |
| Payload CMS | Config-driven Globals | No | Typed fields map to typed columns |
| Dub.co | Typed columns on workspace | No | JSON only for ephemeral UI state |
| Strapi | KV store (core_store) | Yes | Only project using KV; documents the pain points |
Implementation Details
Schema
/**
* Public site settings - safe for themes, Content API, edge caching.
*
* Includes everything a theme or public consumer needs:
* - Identity (description)
* - Branding (logo, icon, colors)
* - Locale (timezone, language)
* - SEO defaults (meta tags, OG image)
*/
export const siteSettingsPublic = pgTable('site_settings_public', {
siteId: uuid('site_id')
.primaryKey()
.references(() => sites.id, { onDelete: 'cascade' }),
// Identity
description: text('description'),
// Branding
logo: text('logo'),
icon: text('icon'),
coverImage: text('cover_image'),
accentColor: varchar('accent_color', { length: 7 }),
// Locale
timezone: text('timezone').default('Etc/UTC').notNull(),
locale: varchar('locale', { length: 10 }).default('en').notNull(),
// SEO defaults
metaTitle: varchar('meta_title', { length: 300 }),
metaDescription: varchar('meta_description', { length: 500 }),
ogImage: text('og_image'),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdate(() => new Date()),
});
/**
* Private site settings - admin only, may contain secrets.
*
* Empty for Phase 1. Will contain:
* - Phase 3: Stripe configuration (account ID, secret key)
* - Phase 4: Email configuration (sender, provider credentials)
*/
export const siteSettingsPrivate = pgTable('site_settings_private', {
siteId: uuid('site_id')
.primaryKey()
.references(() => sites.id, { onDelete: 'cascade' }),
// Phase 3: Membership/Stripe
// stripeAccountId: text('stripe_account_id'),
// stripeSecretKey: text('stripe_secret_key'), // encrypt at rest
// Phase 4: Email
// emailFromAddress: text('email_from_address'),
// emailReplyTo: text('email_reply_to'),
// emailProvider: text('email_provider'),
// emailProviderConfig: jsonb('email_provider_config'),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull()
.$onUpdate(() => new Date()),
});API Design
Public endpoint (edge-cacheable, no auth required):
GET /api/site
Response: { name, description, logo, icon, accentColor, timezone, locale, metaTitle, ... }
Cache-Control: public, max-age=60, stale-while-revalidate=300Private endpoint (admin only):
GET /api/settings
Response: { public: {...}, private: {...} }
Cache-Control: private, no-store
PATCH /api/settings
Body: { public?: {...}, private?: {...} }Row Creation Strategy
Settings rows are created when the site is created (in the same transaction), not lazily. This ensures:
- No null checks needed in application code
- Defaults are applied at creation time
- Queries always return data
// In site creation service
await db.transaction(async (tx) => {
const [site] = await tx.insert(sites).values({ name }).returning();
await tx.insert(siteSettingsPublic).values({ siteId: site.id });
await tx.insert(siteSettingsPrivate).values({ siteId: site.id });
return site;
});Adding New Settings
When adding a new setting:
- Determine visibility - Is it safe for public/themes, or admin-only/sensitive?
- Add typed column - Migration adds nullable column with default
- No backfill needed - Nullable columns don't require data migration
- Update validation - Add to Zod schema for API validation
This is more explicit than KV store but provides compile-time safety and database constraints.
Consequences
Positive
- Type safety - Database schema, Drizzle types, and Zod validation all aligned
- Security by design - Can't accidentally expose private config
- Performance - Public settings edge-cacheable, private settings isolated
- Queryable - Can filter/index on any setting
- AI-friendly - Clear schema for LLM code generation
Negative
- Migration required for new settings - Unlike KV store, adding a setting needs a migration
- Two tables to manage - Though usually you're working with one at a time
- Private table empty for Phase 1 - But it's ready for Phase 3-4
Trade-offs Accepted
The migration overhead for new settings is acceptable because:
- Settings are added rarely (a few per phase, not daily)
- Type safety catches errors at compile time, not runtime
- The security benefit of separation outweighs the convenience of KV
References
- Issue #291: Epic: Site Settings & Configuration
- PRD:
docs/decisions/000-product-requirements.md - Cal.com:
packages/prisma/schema.prisma(Team, OrganizationSettings models) - Directus:
packages/types/src/settings.ts,api/src/database/seeds/ - Payload CMS: Globals documentation
- Dub.co:
packages/prisma/schema.prisma(Project model) - Strapi:
packages/core/core/src/services/core-store.ts