← Coachly

Architecture

Coachly is a German AI career-coaching demo built to make its engineering legible: a multi-tenant app where the database — not application code — enforces isolation, conversation state is owned rather than rented, and the AI layer is swappable and testable.

Request & data flow

  1. 1. Browser (SvelteKit, Svelte 5 runes)

    Sends cookies; renders streamed responses.

  2. 2. Server hooks

    Attaches a cookie-backed Supabase client; revalidates the JWT against GoTrue (getUser), not just the cookie.

  3. 3. load / +server / actions

    Each self-gates: requireUser (and requireCoach for staff). Identity comes from the session, never request input.

  4. 4. User-scoped Supabase client

    Every query runs as the authenticated user — no service-role in the request path (except server-owned usage caps).

  5. 5. Postgres + Row Level Security

    The database enforces per-tenant isolation on every table and storage object.

Every hop runs as the authenticated user; RLS is the backstop.

Three RLS surfaces

Per-user table RLS

Every table (profiles, chat, assistant data) has policies scoped to the authenticated role and keyed on auth.uid(). No USING (true) — ever.

Owner-folder storage RLS

Private buckets (avatars, CVs, vision boards) key each object to its owner via the first path segment; the server derives the path from the session; reads use short-lived signed URLs.

Coach second RLS surface

A coach_users membership table + a SECURITY DEFINER is_coach() function drive additive policies. A coach reads clients and mints codes through the normal client — the database widens access — with no service-role in the coach path and no self-promotion.

Owned state & the AI layer

Conversation history lives in our own chat_threads / chat_messages tables (not a vendor's managed threads), which is what lets the AI provider be swapped freely. An AIProvider interface has a real streaming implementation and a deterministic mock selected by env, so CI and E2E run with no live keys and stable output. Function-calling tool schemas and argument validation are defined in our code.

Decision records

Full ADRs live in docs/adr/ in the repository.