Architecture
This page describes the system design of tripplan.ing — how requests flow through the app, how multi-tenancy works at the code level, and how the runtime abstraction bridges Cloudflare and Node environments.
Request lifecycle
Every HTTP request passes through hooks.server.ts before reaching a route handler. Here's the full flow:
Request arrives
│
├─ Security headers applied (CSP, HSTS, X-Frame-Options)
│
├─ Session parsed from cookie
│ ├─ Localhost dev bypass → synthetic session
│ ├─ Cookie present → KV lookup for session
│ └─ No cookie → session = null
│
├─ Is this a platform route? (/platform/* or /api/platform/*)
│ ├─ Yes → require session + operator context
│ │ └─ getOperatorContext() checks roles
│ └─ No → continue to event resolution
│
├─ Resolve hostname to event
│ └─ resolveEventByHostname() → platform_event_domains lookup
│
├─ Auth routes? (/auth, /api/auth)
│ └─ Yes → pass through (no guards)
│
├─ Lifecycle gates (if event resolved)
│ ├─ suspended → 503 for non-admin routes
│ └─ read-only → 503 for non-GET methods
│
├─ Protected route guard
│ └─ /photos, /polls, /profile, /documents, /rsvp → require session
│
└─ Admin route guard
└─ /admin/* → require session + email in adminEmailsHooks implementation
The hooks handler (hooks.server.ts) performs all pre-route processing:
export const handle: Handle = async ({ event, resolve }) => {
const env = await getRuntimeEnv(event.platform);
// Session parsing
if (isLocalDev(event.platform, event.url.hostname)) {
event.locals.session = { email: 'dev@localhost' };
} else if (sessionId) {
event.locals.session = await getSession(env.kv, sessionId);
}
// Platform route handling
if (isPlatformRoute) {
const ctx = await getOperatorContext(env.db, email, env.PLATFORM_OPERATOR_EMAILS);
if (!ctx) throw redirect(302, '/auth');
event.locals.operatorRole = ctx.role;
return resolve(event);
}
// Event resolution
const resolvedEvent = await resolveEventByHostname(env.db, hostname);
event.locals.eventId = resolvedEvent?.id;
// Lifecycle + route guards...
return resolve(event);
};What event.locals contains
After hooks processing, route handlers access these via locals:
| Field | Type | Set when |
|---|---|---|
session | { email: string } | null | Always |
eventId | string | undefined | Event hostname resolved |
isPlatformRoute | boolean | /platform/* routes |
operatorEmail | string | Platform routes (authenticated) |
operatorRole | 'super_admin' | 'admin' | Platform routes (authenticated) |
operatorUserId | string | Platform routes (authenticated) |
Multi-tenancy implementation
Hostname resolution
The resolveEventByHostname() function queries the platform_event_domains table:
SELECT event_id FROM platform_event_domains WHERE hostname = ?Each event can have multiple domains (e.g., reunion.tripplan.ing and reunion.example.com), with one marked as primary. In local development, if no hostname match is found, the system falls back to the first available event.
Event-scoped data access
All event-scoped tables (rsvps, payments, schedule_items, etc.) include an event_id column. Every query must scope by it:
// Correct: scoped by eventId
const payments = await env.db
.select()
.from(paymentsTable)
.where(and(eq(paymentsTable.eventId, eventId), eq(paymentsTable.status, 'completed')));
// Wrong: missing eventId — would leak data across events
const payments = await env.db
.select()
.from(paymentsTable)
.where(eq(paymentsTable.status, 'completed'));Platform tables vs event tables
The database has two families of tables:
Platform tables (no event_id — global scope):
platform_users,platform_roles— operator accountsplatform_events— event metadata and lifecycleplatform_event_domains— hostname mappingsplatform_organizations,platform_org_members— org groupingplatform_audit_logs— operator action tracking
Event tables (all have event_id):
settings,rsvps,attendees,payments,payment_itemsschedule_days,schedule_items,schedule_item_permissionsphotos,documents,document_permissionspolls,poll_options,poll_votesannouncements,content_sections,content_section_permissionsaccess_requests,groups,group_memberspricing_tiers,add_ons,custom_fields,custom_field_options
Runtime abstraction
The app defines a common AppEnv interface implemented by two runtimes:
interface AppEnv {
db: Database; // Drizzle ORM instance
kv: KvStore; // Key-value store (sessions, OTPs)
blobs: BlobStore; // Object storage (photos, documents)
// Environment variables
STRIPE_SECRET_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
MAILGUN_API_KEY: string;
MAILGUN_DOMAIN: string;
PLATFORM_OPERATOR_EMAILS: string;
PLATFORM_DOMAIN_SUFFIX: string;
// ... PayPal keys
}Runtime selection
getRuntimeEnv() detects the environment automatically:
export async function getRuntimeEnv(platform?: App.Platform): Promise<AppEnv> {
if (platform?.env) {
return getCloudflareRuntime(platform.env); // CF bindings present
}
const { getNodeRuntime } = await import('./node.js');
return await getNodeRuntime(); // Dynamic import avoids bundling Node deps in CF
}The Node runtime is dynamically imported to prevent better-sqlite3 from being included in the Cloudflare Worker bundle.
Cloudflare runtime
Binds to D1, KV, and R2 from the Worker's environment:
function getCloudflareRuntime(env: Env): AppEnv {
const db = drizzle(env.DB);
const kv: KvStore = env.KV; // CF KV natively implements the KvStore interface
const blobs: BlobStore = {
put: (key, data, ct) => env.R2.put(key, data, { httpMetadata: { contentType: ct } }),
get: async (key) => { /* R2 get wrapper */ },
delete: (key) => env.R2.delete(key)
};
return { db, kv, blobs, ...env };
}Node runtime
Uses SQLite, in-memory KV, and filesystem storage:
async function getNodeRuntime(): Promise<AppEnv> {
const sqlite = new Database(dbPath);
const db = drizzle(sqlite);
const kv = new InMemoryKvStore(); // Map-based, lost on restart
const blobs = new FilesystemBlobStore(filesDir);
return { db, kv, blobs, ...process.env };
}The Node runtime automatically applies migrations and seeds demo data on first startup.
Settings system
Event configuration is built dynamically by getMergedConfig() in settings.ts:
platform_events table (bootstrap values)
+ settings table (admin overrides)
+ pricing_tiers table
+ add_ons table
+ custom_fields table
→ EventConfig object (cached 5 seconds)This approach means:
- Organizers change settings in the admin UI, not config files
- Changes take effect immediately (after cache TTL)
- The platform event provides bootstrap values (name, dates, admin email)
- The settings table provides organizer overrides
- Related tables (tiers, add-ons, fields) are loaded separately
Module structure
apps/event-site/src/
├── hooks.server.ts # Request lifecycle (auth, routing, guards)
├── lib/
│ ├── server/
│ │ ├── auth.ts # OTP generation, verification, sessions
│ │ ├── settings.ts # EventConfig merging from DB
│ │ ├── runtime/ # Runtime abstraction
│ │ │ ├── index.ts # getRuntimeEnv() entry point
│ │ │ ├── types.ts # AppEnv, BlobStore interfaces
│ │ │ ├── cloudflare.ts # D1 + KV + R2 bindings
│ │ │ ├── node.ts # SQLite + memory + filesystem
│ │ │ └── resolve-event.ts # Hostname → event lookup
│ │ ├── platform/ # Platform management
│ │ │ └── auth.ts # Operator context + role checks
│ │ └── db/
│ │ ├── schema.ts # Drizzle table definitions
│ │ └── index.ts # Database type exports
│ ├── components/ # Svelte components
│ └── types.ts # Shared TypeScript interfaces
├── routes/
│ ├── admin/ # Event admin (organizer UI)
│ ├── platform/ # Platform management UI
│ ├── api/ # API endpoints
│ └── ... # Public event routes
└── app.css # Tailwind entry pointRelated pages
- Auth System — deep dive into OTP, sessions, and access control
- Database — schema details and Drizzle patterns
- Code Patterns — Svelte 5 and server-side conventions
- Core Concepts — high-level overview for non-developers