Skip to content

Core Concepts

This page explains the foundational design decisions behind tripplan.ing. Understanding these concepts will help you navigate the codebase and make informed decisions when configuring or extending the platform.

Multi-Tenancy

tripplan.ing serves multiple events from a single deployment. Every request is routed to the correct event by hostname.

How hostname resolution works

When a request arrives, hooks.server.ts calls resolveEventByHostname() to look up the hostname in the platform_event_domains table:

Request to reunion.tripplan.ing
  → DNS resolves to the Cloudflare Worker
  → hooks.server.ts extracts hostname
  → resolveEventByHostname() queries platform_event_domains
  → Finds event_id for "reunion.tripplan.ing"
  → Sets locals.eventId for all downstream handlers

Each event gets one or more domains. The platform_event_domains table maps hostnames to event IDs, with one marked as primary.

Data isolation

All event-scoped database tables include an event_id column. Every query must filter by event_id to maintain isolation:

typescript
const rows = await env.db
  .select()
  .from(rsvps)
  .where(and(eq(rsvps.eventId, eventId), eq(rsvps.status, 'confirmed')));

Shared resources (D1, KV, R2) are partitioned by convention:

  • Database: event_id column on all event tables
  • KV: Keys prefixed with purpose (e.g., otp:email, session:id)
  • R2/Blobs: Object keys scoped by event context

Platform vs event routes

The app serves two categories of routes from one worker:

Route prefixPurposeAuth required
/platform/*Manage events, organizations, usersPlatform operator
Everything elseEvent-specific pagesVaries by route

Platform routes are gated by getOperatorContext(), which checks if the user's email is in PLATFORM_OPERATOR_EMAILS or has a role in the platform_users / platform_roles tables.

Event Lifecycle

Each event has a lifecycle state that controls behavior:

StateWrite operationsPublic accessAdmin access
activeAllowedFull siteFull
post-eventRead-onlyPhotos, documents, polls onlyFull
suspendedBlocked503 errorAllowed
draftAllowedNot publicly routedAllowed
archivedRead-onlyNot publicly routedRead-only

The lifecycle is set in the platform UI or event admin settings. The mode field in event settings controls the active/post-event transition, while the platform status field handles draft/suspended/archived.

In post-event mode:

  • The homepage redirects to photos or a post-event landing
  • RSVPs, payments, and schedule are hidden from navigation
  • Photos, documents, and polls remain accessible to authenticated users
  • All write operations (POST, PUT, DELETE) return 503 Read-only

Authentication

tripplan.ing uses a custom email OTP (one-time password) system with no external auth library. The entire implementation is ~130 lines.

Sign-in flow

1. User enters email at /auth
2. Server generates 6-digit OTP → stores in KV (10-min TTL)
3. Server sends OTP via Mailgun email
4. User enters OTP
5. Server verifies OTP → creates session in KV (7-day TTL)
6. Session ID stored in cookie

Rate limiting

  • OTP generation: Max 5 codes per email per 10-minute window
  • OTP verification: Max 3 attempts per code (code deleted after exceeding)
  • Timing-safe comparison: OTP verification uses SubtleCrypto HMAC to prevent timing attacks

Session management

Sessions are stored in KV with a 7-day TTL. Each session includes a generation counter that enables "log out everywhere":

session:abc123 → { email: "user@example.com", gen: 2 }
session-gen:user@example.com → "2"

When invalidateUserSessions() is called, it increments the generation counter. Existing sessions with a lower generation are rejected on next use.

Access control layers

Access is enforced at multiple levels:

  1. Route guards (hooks.server.ts): Protected paths require a valid session. Admin paths additionally require the user's email in adminEmails.
  2. Allowed emails (settings.allowedEmails): Controls which emails can sign in to the event.
  3. Access requests (access_requests table): Users not on the allowed list can request access, which organizers approve/deny.
  4. Group permissions: Documents, schedule items, and content sections can be restricted to specific emails or groups.

Runtime Abstraction

The app runs on two different runtimes through a common interface:

AppEnv interface

typescript
interface AppEnv {
  db: Database;      // Drizzle ORM instance
  kv: KvStore;       // Key-value store
  blobs: BlobStore;  // Object/file storage
  // Environment variables (Stripe, Mailgun, PayPal, platform config)
}

Cloudflare runtime (production)

  • Database: Cloudflare D1 (SQLite at the edge)
  • KV: Cloudflare KV (global key-value store)
  • Blobs: Cloudflare R2 (S3-compatible object storage)
  • Bindings accessed from platform.env in SvelteKit

Node runtime (local dev / Docker)

  • Database: better-sqlite3 (local SQLite file)
  • KV: In-memory Map (data lost on restart)
  • Blobs: Local filesystem (data/objects/)
  • Auto-creates database and applies migrations on startup

The runtime is selected automatically in getRuntimeEnv():

typescript
export async function getRuntimeEnv(platform?: App.Platform): Promise<AppEnv> {
  if (platform?.env) {
    return getCloudflareRuntime(platform.env);  // Cloudflare bindings present
  }
  const { getNodeRuntime } = await import('./node.js');
  return await getNodeRuntime();  // Fall back to Node
}

Settings System

Event configuration is database-driven, not file-driven. The getMergedConfig() function in settings.ts builds an EventConfig by:

  1. Reading the settings table for the event
  2. Reading pricing_tiers, add_ons, and custom_fields tables
  3. Merging database values with defaults (database wins)
  4. Bootstrapping adminEmails from the platform event's adminEmail field
  5. Caching the result for 5 seconds to avoid repeated queries

This means organizers can change any setting through the admin UI and see it take effect immediately — no redeploy needed.

  • Architecture — deep dive into the request lifecycle and module structure
  • Auth System — implementation details of the OTP and session system
  • Database — schema families, Drizzle patterns, and migrations

Released under the MIT License.