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.