Architecture overview
This is the developer’s orientation tour. The binding, exhaustive version is
docs/ARCHITECTURE.md
in the platform repository — 15 sections, each pattern specified before it
was built.
The three tiers
Section titled “The three tiers”Kernel — never optional, no manifest, no toggle: auth & RBAC, the competition entity and tenancy scoping, the event bus, and the module loader itself. Everything else is built on these.
Required core — what makes the platform a CTF tool at all: challenges, scoring/scoreboard, hints, tickets, announcements, notifications, dashboard, collab notes, users, roles, SSO, setup, site settings, audit log, teams, custom pages, and the competitions module itself — seventeen modules. Organised as modules (same registration path as everything else) but not user-toggleable. Custom pages is required-core with no toggle because content is the on/off switch — an install with no pages renders no sidebar section.
Optional modules — seven of them: Automations, Feedback,
Analytics, AI Assistants, Certificates, Post-event
reports, and Challenge Instances (v1.6.0): toggleable per competition.
Twenty-four modules in all. Two ship inert even when enabled for a
competition, behind a site master switch: AI Assistants
(ai_settings.enabled, default off; ADR-0023) and Challenge Instances,
which stays refused until an operator configures a container backend and
flips its site-level switch (ADR-0036). Marketplace /
third-party modules are a future concern with an explicit sandboxing
question to answer first — today’s module system runs trusted, in-repo code.
The event-driven core
Section titled “The event-driven core”Every mutation emits a <entity>.<verb> event through an async in-process
bus — per worker, under multi-worker: each event’s
handlers run once, on the emitting worker; only the WebSocket fan-out
crosses workers. Consumers subscribe independently — core code never
knows who’s listening:
core mutation ─▶ event bus ─┬─▶ audit log (synchronous lane) ├─▶ WS broadcasts (synchronous lane) ├─▶ automation engine (background lane) └─▶ notificationsDispatch has two lanes (ADR-0012): foreground handlers are awaited
before the request returns (the audit log is lossless by construction);
background=True handlers are fire-and-forget so a slow webhook can’t block
a flag submission. Handlers are tagged with their owning module and fail in
isolation.
Tenancy
Section titled “Tenancy”Every tenant-scoped table carries competition_id, enforced at the
data-access layer rather than per-endpoint discipline. Cross-competition
reads exist only behind explicit global permissions (the admin overview).
One deployment, many isolated competitions.
Permissions are a
catalogued list checked by one shared
require_permission dependency; roles are data; system roles re-sync from
the catalogue at startup. Identity is username-first with optional email —
arriving via local login (which also serves LDAP directory users through a
bind fallback), SSO (OIDC or SAML, one IdentityProvider
framework with a kind per protocol — ADR-0022 generalizing ADR-0021), or
a personal API token, all of which resolve to the same current_user and
the same session contract. Sessions are short-lived JWTs plus rotating hashed refresh
sessions; secrets follow ADR-0020 (hash what’s only verified, encrypt what
must be retrieved).
The real-time layer
Section titled “The real-time layer”- Scoped WS rooms per resource (
/ws/<type>/<id>), a per-user notification room, and collab-note relay rooms. Two room idioms coexist: snapshot rooms (scoreboard, announcements) push full shared state, while ping rooms — tickets, and the per-competitionactivity/<id>room — push tiny id-only frames and let each client refetch its own permission-filtered REST slice. The activity room fans out a curated allowlist of events; on the frontend, making a surface live is one entry in an event-to-query-invalidation map, not a new socket. - Auth is a first-frame handshake — tokens never appear in URLs.
- Presence is ephemeral WS state with debounced clearing.
- Since v1.4.0 the layer is multi-worker-capable: with
WEB_CONCURRENCY>1, broadcast frames relay across workers over one Redis pub/sub channel behind the existing manager singleton (ADR-0025 — the broadcast call sites are untouched), and presence becomes a hybrid of the local debounced model plus a Redis liveness store with heartbeat-refreshed expiry, so a crashed worker’s members age out (ADR-0026). Single-worker attaches neither and is byte-for-byte the ADR-0005 behaviour. The v1.4.0 read-path additions — the TTL-backstopped scoreboard cache and the activity-burst coalescer — are per-worker, efficiency-only. - Collaborative prose uses Y.js CRDTs with a dumb-relay transport (ADR-0014): the server relays opaque update frames and persists one snapshot blob per document — it never decodes the CRDT.
Repository layout
Section titled “Repository layout”backend/ models/ SQLAlchemy models schemas/ Pydantic (never return a model) routers/ one FastAPI router per domain plugins/ the modules — one directory per module (see Developing modules) utils/ event bus, catalogs, automation engine, scoreboard, backup… auth/ permission catalogue, deps, identity, seeding alembic/ migrations (one per PR, YYYY-MM-DD_<revid>_<desc>.py)frontend/ src/app/ Next.js App Router pages src/components/ ui/ primitives + one directory per domain src/lib/hooks/ one TanStack Query hook module per domain src/stores/ Zustand (client state only)Design system
Section titled “Design system”Colour and radius live in HSL channel tokens consumed through Tailwind v4’s
@theme; components reference semantic tokens (bg-primary), never hex.
That token layer is what makes the five shipped palettes and the admin
accent override work at runtime with no rebuild — and it’s the same system
this docs site and flagpost.io are skinned with.