Skip to content

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 adminEmails

Hooks implementation

The hooks handler (hooks.server.ts) performs all pre-route processing:

typescript
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:

FieldTypeSet when
session{ email: string } | nullAlways
eventIdstring | undefinedEvent hostname resolved
isPlatformRouteboolean/platform/* routes
operatorEmailstringPlatform routes (authenticated)
operatorRole'super_admin' | 'admin'Platform routes (authenticated)
operatorUserIdstringPlatform routes (authenticated)

Multi-tenancy implementation

Hostname resolution

The resolveEventByHostname() function queries the platform_event_domains table:

sql
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:

typescript
// 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 accounts
  • platform_events — event metadata and lifecycle
  • platform_event_domains — hostname mappings
  • platform_organizations, platform_org_members — org grouping
  • platform_audit_logs — operator action tracking

Event tables (all have event_id):

  • settings, rsvps, attendees, payments, payment_items
  • schedule_days, schedule_items, schedule_item_permissions
  • photos, documents, document_permissions
  • polls, poll_options, poll_votes
  • announcements, content_sections, content_section_permissions
  • access_requests, groups, group_members
  • pricing_tiers, add_ons, custom_fields, custom_field_options

Runtime abstraction

The app defines a common AppEnv interface implemented by two runtimes:

typescript
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:

typescript
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:

typescript
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:

typescript
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 point

Released under the MIT License.