Assets
Assets is the source of truth for everything connected to MapexOS. It owns what an asset is, how its payloads become normalized events, how it authenticates, and whether it is online right now. It does not ingest, transform, or route events itself — it configures the entities the rest of the pipeline acts on, and it authors the cache that every other service reads. When the MQTT broker, LNS, Router, and JS execution need to know something about an asset, they read it from their own tiered cache — and the bottom of every one of those caches falls back to Assets, the one place that can always reconstruct the truth.
It carries two responsibilities: the model (Assets + Asset Templates) and the device control plane (PKI + presence/health).
Port
5002· Go (DDD + Hexagonal) · MongoDB (write model) + MinIO/S3 (read-model projections) + Redis (health hot-state) · mapexVault for the signing CA and key-encryption key · four modules:assets,assettemplates,healthmonitor,mqttcerts.
Asset & Template — onboard by configuration, not code
The model has two halves:
- An Asset Template is a reusable blueprint: its classification (category / manufacturer / model / version), the
assetIdPaththat locates the device id in a payload, the transform scripts, and the EVA field schema. Two scripts are required —scriptValidatorandscriptConversion— and two are optional —scriptProcessor(preprocess) andscriptTest(for the in-console test runner). - An Asset is one device instance bound to a template, carrying its protocol identity, route groups, health config, and active certificate.
assetUUIDis its stable, cross-platform business id.
One template serves thousands of assets across hundreds of sites. A new vendor or model is a template configuration, never a new code branch — this is what turns device onboarding from an engineering task into a setup task.
EVA dynamic fields — where the fieldId is born
A template declares its dynamic fields, and this is the origin of the typed columnar storage you saw in Events. Each field is assigned a numeric id with a hard contract:
fieldIdis auint16, immutable once assigned (a per-templatenextFieldIdcounter only ever increments — ids are never reused),- a declared
type(number·string·bool·date·geo), - a soft-delete
status(a removed field is deprecated, never physically deleted), - up to 200 active fields per template.
That immutability is the contract between Assets and Events: Assets assigns the fieldId, Events stores values under it in its typed EVA maps. Because ids never move, history stays coherent forever.
The source of truth — and the cache every service is rebuilt from
This is the architectural center of gravity. On every change that affects how an asset is read or authenticated, Assets writes two denormalized projections to MinIO/S3:
| Projection | Path | For |
|---|---|---|
| Full read-model | mapex-assets/{orgId}/{assetUUID}.json |
Router, JS-execution, Events — full asset context. |
| Slim auth projection | mapex-asset-auth/{assetUUID}.json |
the MQTT broker plugin and LNS — just what's needed to authenticate. |
Then it fires a FANOUT invalidation on NATS so every replica of every consumer drops its stale copy. The read-model is always written before the invalidate, so a service that re-fetches on the invalidation sees fresh data, never a gap.
┌──────────── Assets (source of truth) ────────────┐
write change → │ MongoDB (write model) │
│ │ project │
│ ▼ │
│ MinIO mapex-assets/… · mapex-asset-auth/… │
│ │ then │
│ ▼ │
│ FANOUT invalidate ─────────────────────────────┐│
└──────────────────────────────────────────────────┘
▼
consumer cache: L0 RAM → L1 disk → L2 MinIO → L3 HTTP fallback → Assets
(the fallback always reconstructs from the source of truth)
Every consumer — broker, LNS, Router, JS-execution — runs its own TieredCache, and the last tier of all of them falls back over HTTP to Assets (GET /internal/assets/:assetUUID), which repopulates the cache inline. No matter how cold a cache gets, there is always one authoritative place to rebuild it from.
Authentication happens at the edge — Assets never authenticates
Assets authors the credentials but never validates a connection itself. The slim auth projection is already at the edge, so the servers that terminate the protocol decide every connection locally:
- The MQTT broker plugin decides every MQTT
CONNECTfrom the projection'smqttblock alone — bcrypt-comparing the presented password againstpasswordHash, or matching the client certificate's serial againstcurrentCertSerial. - The LNS authenticates LoRaWAN joins from the projection's
lorawanblock — the device identity and its envelope-encrypted keys (gateways authenticate by certificate serial).
There is no per-connection HTTP callout to Assets — the only edge→Assets HTTP path is the L3 cache-miss fallback above. Authentication is fast, made where the connection lands, and keeps working even while Assets is briefly unavailable.
Device PKI control plane
Assets is a local certificate authority for devices (mqttcerts module). It issues X.509 client certificates — ECDSA P-256, 128-bit serial, client-auth extended key usage — signed by an intermediate CA fetched once from mapexVault and held in RAM (atomic.Pointer, never written to disk; a caReady gate returns 502 until the CA is loaded, with backoff retry on failure).
- One active certificate per asset — re-issuing requires
force=true, otherwise409. - The certificate and private key PEM are returned exactly once at issue time and never persisted — the asset keeps only metadata (serial, fingerprint, subject CN, issued/expires).
- Revocation is a hard delete plus a tamper-evident audit row that auto-expires after 30 days (a MongoDB TTL index).
The same machinery issues certificates for LoRaWAN gateways.
Connectivity health & presence
healthmonitor is a real near-real-time engine tracking each asset's online / offline / last-seen. It learns liveness two ways:
- Heartbeats on
asset.heartbeat.>— implicit (js-execution emits one per data event) or explicit (an HTTPPOST /api/v1/heartbeat, or MQTT broker presence advisories for MQTT assets) — refreshing a last-seen timestamp in Redis. - A periodic scan that marks an asset offline once it has been silent past its threshold for
requiredMissesconsecutive sweeps (cutoff = now − threshold).
It runs in three modes per asset — disabled, monitor-only (persist state), or monitor + route — and on each transition it publishes two things: an asset_status_save to Events (the queryable connectivity history) and, when the asset configures them, a route.execute with eventSource = healthStatus to the Router (which routes presence changes only to trigger / workflow). Transitions are exactly-once across replicas (an atomic Redis SREM), with an anti-race guard for multi-broker reconnects. Enabling health monitoring requires at least one offline/online route group — a fail-fast 422 otherwise.
Multi-protocol identity & secrets
An asset speaks one of three protocols, and no secret is ever stored in plaintext:
| Protocol | Identity & secret handling |
|---|---|
| HTTP | No device credential — admitted at the HTTP Gateway by its DataSource. |
| MQTT | password xor cert (mutually exclusive). Passwords are bcrypt-hashed; certs come from the PKI plane. |
| LoRaWAN | Device (OTAA/ABP) or gateway. Device root/session keys are envelope-encrypted with a mapexVault KEK — only the four envelope fields persist; the runtime consumer is the LNS. |
Plaintext (a generated password, a key, a cert PEM) is shown to the operator once at create/issue time and is irretrievable afterward.
Inputs & outputs
HTTP (in): /api/v1/assets and /api/v1/asset_templates CRUD (JWT, permission- and coverage-gated); /api/v1/mqtt_certs and /api/v1/gateway_certs (issue/revoke, gated on CA readiness); internal API-key routes for the L3 cache fallback (GET /internal/assets/:assetUUID, …/asset_auth/:assetUUID, …/scripts/:assetUUID).
NATS (consumed): asset.heartbeat.>, MQTT presence advisories, the periodic health scan, and classification-name updates.
NATS (produced): fanout.asset.invalidate and fanout.template.invalidate (cache coherence), events.asset_status_save (connectivity history → Events), and route.execute (eventSource=healthStatus → Router) on health transitions.
Object storage: the two MinIO projections above, plus template scripts ({orgId|mapexos_public}/{templateId}.json) for JS-execution.
Where to go next
| To dive into… | Go to |
|---|---|
| Where templates' scripts run | JS Execution |
| Where dynamic fields are stored & queried | Events |
| Where presence changes are routed | Router |
| The CA and KEK behind the control plane | Vault |
MapexOS