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 handlersEach 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:
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_idcolumn 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 prefix | Purpose | Auth required |
|---|---|---|
/platform/* | Manage events, organizations, users | Platform operator |
| Everything else | Event-specific pages | Varies 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:
| State | Write operations | Public access | Admin access |
|---|---|---|---|
active | Allowed | Full site | Full |
post-event | Read-only | Photos, documents, polls only | Full |
suspended | Blocked | 503 error | Allowed |
draft | Allowed | Not publicly routed | Allowed |
archived | Read-only | Not publicly routed | Read-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 cookieRate 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:
- Route guards (
hooks.server.ts): Protected paths require a valid session. Admin paths additionally require the user's email inadminEmails. - Allowed emails (
settings.allowedEmails): Controls which emails can sign in to the event. - Access requests (
access_requeststable): Users not on the allowed list can request access, which organizers approve/deny. - 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
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.envin 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():
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:
- Reading the
settingstable for the event - Reading
pricing_tiers,add_ons, andcustom_fieldstables - Merging database values with defaults (database wins)
- Bootstrapping
adminEmailsfrom the platform event'sadminEmailfield - 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.
Related pages
- 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