Skip to content

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.

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.

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)
└─▶ notifications

Dispatch 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.

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).

  • 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-competition activity/<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.
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)

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.