Server — the brain

The server is a Ktor (Netty) application that holds the desired policy for every device, stores the latest report, and is the authoritative judge of compliance. It binds 0.0.0.0:8081.

Source: server/src/main/kotlin/com/arganaemre/mdmcore/ (Application.kt, Doorbell.kt).

What it stores: DeviceStore

DeviceStore is an in-memory singleton — three ConcurrentHashMaps plus an AtomicInteger:

Map Key Value
policies device id the desired DevicePolicy
reports device id the latest ComplianceReport
tokens device id the FCM registration token
  • Identity is a running counter: each enroll yields device-1, device-2, … (AtomicInteger).
  • The default policy handed to every new device is version = 1, cameraDisabled = true — non-trivial on purpose, so a fresh agent has something real to enforce and report.
  • updatePolicy is atomic per device via computeIfPresent: it read-bump-writes the version and only acts if the device exists (an unknown id stays absent → 404).
  • Nothing is persisted. Restart the server and every device, policy, and token is gone — which is exactly why the agent treats a 404 on policy-pull as “re-enroll me” (see agent.md). A real deployment would back this with a database.

How it decides compliance: evaluate()

The server never trusts the agent’s self-reported compliant flag. On every status read it recomputes the verdict from the reported actual state, using the shared rule (DevicePolicy.isSatisfiedBy, see protocol.md):

when {
    report == null                                -> UNKNOWN
    report.appliedPolicyVersion < desired.version -> STALE
    desired.isSatisfiedBy(report)                 -> COMPLIANT
    else                                          -> NON_COMPLIANT
}

STALE is checked before content, and is only knowable because the policy is versioned. Full semantics: the loop.

Routes

Eight routes, defined in Application.module(). Full reference with bodies, status codes, and curl: http-api.md. In brief:

  • POST /enroll, GET|PUT /devices/{id}/policy, POST /devices/{id}/token, POST /devices/{id}/report, GET /devices, GET /devices/{id}/status, and GET / (health).
  • PUT /devices/{id}/policy is the console’s edit path: it bumps the version and rings the device’s FCM doorbell (best-effort) so the change propagates promptly.

CORS (dev-only)

The server installs CORS with anyHost() and allows GET/POST/PUT plus the Content-Type header. This exists so the browser-based web console (a different origin) can call the API, and because a JSON body triggers a CORS preflight that must permit Content-Type and the PUT method (only GET/POST/HEAD are allowed by default). anyHost() is unacceptable in production — restrict it before any real deployment.

Content negotiation

install(ContentNegotiation) { json(MdmJson) } teaches the server to read/write the contract as JSON using the contract’s own MdmJson config, so the server and agent serialize identically. See protocol.md.

Push: the Doorbell

Doorbell sends the data-only FCM wake message when policy changes. It degrades gracefully: with no credential configured, it’s a no-op and the server (and its tests) run fine — you just don’t get push wake-ups; agents still catch up on their next sync. At boot the server logs which mode it’s in:

FCM doorbell: ready — policy changes will wake devices via push.
FCM doorbell: NOT configured — push wake disabled; agents still sync over HTTP.

The full push design is in fcm-doorbell.md; credential setup is in firebase-setup.md.

Running it

./gradlew :server:run     # embedded Netty on http://localhost:8081

:server:run is a long-lived task that holds the Gradle project lock — any other ./gradlew command will block until it stops. For end-to-end work, build other artifacts first, then start the server, then drive it with curl/adb. See development.md.

Tests

Server tests use Ktor’s testApplication. Because DeviceStore is a shared in-memory singleton, each test enrolls and uses its own returned DeviceId rather than assuming device-1. The suite and how to run it: development.md.