Skip to content

OTA Registry Spec ​

This is the contract for building an OTA registry: the server that hands out app updates to Mikro.js devices. You run it on whatever backend you already have. The device firmware, the mikro/ota runtime module, and the mikro ota pack / mikro ota push / mikro ota release / mikro ota enroll commands are all part of the Mikro.js repo. This document describes what your server has to do for them to work.

The spec has two parts:

  • Required. The endpoints that the fixed Mikro.js code (the CLI and the runtime) calls. Implement these and the whole toolchain works against your server unchanged.
  • Advisory. The check-in protocol that the reference device app and the reference registry use. Adopt it unless one of the reasons to replace it applies. The device side of a check-in is application code that you write and own, so you can replace this half entirely, as long as the required half still holds.

Background ​

Mikro.js is a JavaScript runtime for microcontrollers. Its over-the-air (OTA) update system replaces a device's app build (the compiled JavaScript, shipped as a gzipped tar) without a cable. The device downloads a build over whatever connection its app has, and the firmware installs it on a trial. If the trial fails, the firmware rolls back automatically.

A registry is the server in that system. It stores builds, answers device check-ins with an update offer (or with nothing), records what each device is running, and provides endpoints for publishing builds and enrolling devices.

Base URL and the /api/v1 prefix. The registry serves its API under /api/v1. Clients are configured with only the registry's origin: on the workstation through .mikro/registry.json (written by mikro ota setup) or --registry, and on the device through enrollment, where it is stored next to the update key. The clients add /api/v1 themselves. Every endpoint path in this document is relative to /api/v1.

The identity document. GET the base url (with Accept: application/json) returns a small JSON document identifying the registry:

json
{"name": "mikro-registry", "version": "<build id>"}

name must be exactly mikro-registry; that is how mikro ota setup recognizes a registry before it commits a token, so a typo'd or wrong url is caught up front rather than at the first publish or enroll. It warns and asks whether to continue (it does not hard-fail) when the url answers without this document, since a proxy could rewrite the root. version is an optional build identifier (for example the deployment's commit sha); it is informational, and clients never key on it. Serve a human page to browsers at the same url if you like; API clients ask for JSON.

Required: the contract ​

1. The build artifact ​

mikro ota pack produces a gzipped tar. Its root holds mikro.app.json plus the app/ tree. The manifest is {app, version, firmwareVersion, bytecodeVersion}, with optional source fields {repository, directory, commit, dirty} and, when the app declares a config schema, a configSchema (described under the publish endpoint) plus configDefaults: the partial default config the schema materializes (every field a default fills; possibly {}), which the device spreads a served overlay over and reads on its own when it holds no document (see Config sync). The firmwareVersion/bytecodeVersion fields record what the build was compiled against; they are not a policy about who may run it. The source fields, when present, are captured at pack time (repository and directory from package.json, commit from HEAD, dirty when the repository had uncommitted changes) so a build carries a link back to its source; a registry may store and surface them, and MUST ignore them if it does not.

They live inside the manifest, not merely on the upload, so that a build packed in one CI stage and pushed from another describes the source it was packed from rather than whatever project the pushing machine sits in. A push --tarball holds only these bytes, and for the commit there is no second chance: nothing else can recover it afterwards.

The cost is that the checksum covers them, so the same app tree packed at two commits is two builds, and re-pushing one version from a moved HEAD is a checksum conflict rather than an idempotent no-op.

dirty is repository-wide. It says the build may not match commit, not that it does not: an uncommitted change to a file outside this app sets it too.

The build's identity is the SHA-256 of the .tgz, in lowercase hex. mikro ota pack computes it, and the device firmware verifies it after downloading. Store it and serve it as the offer's checksum. If it does not match, every download fails verification.

2. The publish and release endpoints ​

mikro ota push uploads a build here. POST /builds, multipart/form-data:

PartTypeValue
appstringapplication lineage, from the build's mikro.app.json
versionstring (semver)app version, from the build's mikro.app.json
checksumstringSHA-256 (lowercase hex) of the .tgz
sizestringbuild size in bytes (decimal string)
firmwareVersionstring (semver)from the build's mikro.app.json; must be valid semver
bytecodeVersionstringfrom the build's mikro.app.json (decimal string)
notestring, optionalfree-text note about the build (omitted when unset)
createstring, optionaltruthy flag (--create): create an unknown app on first publish instead of rejecting it
channelstring, optionalserve the stored build on this channel (push --release <channel>); absent stores it without serving
repositorystring, optionalsource repository URL (http(s), no credentials), from the build's mikro.app.json
directorystring, optionalapp's path inside repository (relative, no ..); only valid with repository
commitstring, optionalcommit SHA the build was packed from (hex), from the build's mikro.app.json
dirtystring, optionaltruthy flag: the repository was dirty at pack time; only valid with commit
configSchemastring, optionalserialized config schema (JSON), from the build's mikro.app.json
buildfilethe .tgz (content-type application/gzip)

Auth is Authorization: Bearer <token>. The CLI never parses the token, so any token scheme works; --token and MIKRO_OTA_TOKEN are sent verbatim.

All four are read back off the artifact, so a --tarball push sends exactly what the pack recorded. Each is independently optional: a project with a repository but no git repo publishes one without the other. A registry with an app-level record may prefer to keep repository and directory there, using the first publish that carries them as the seed, since they describe the app rather than the build.

The source fields are asserted by the publisher, not proven. Nothing verifies them against the uploaded .tgz, so they attest to nothing on their own: anyone holding a publish token chooses them. Validate repository as an http(s) URL with no userinfo (a tokenised remote in a publisher's package.json would otherwise be stored and shown), reject a directory that is absolute or contains .., and render the link as untrusted outbound content (rel="noopener noreferrer") rather than as a verified provenance claim.

Storing a build and serving it are separate steps. A build is stored the moment it uploads, but it is offered to a device only once a channel points at it. A channel is a movable pointer to a build, keyed on (app, channel, firmwareRange); the build record stays tag-less (its checksum is still its primary key), so one build can be served on more than one channel. The optional channel part decides serving: absent, it is served to nobody (a bare push); present, that channel is pointed at it. channel=main promotes the build (it sets promotedAt, the pre-channels mechanism described under Offer selection); any other name writes a channel pointer. The default channel is main, so a build or device with no channel reads as main. Channel names must match ^[a-zA-Z0-9][a-zA-Z0-9._-]*$ and be at most 64 characters; an invalid one is a 400.

A build's firmware range is derived from its firmwareVersion at publish time: the left-most non-zero segment, which is where npm caret semantics place breaking changes. That is 1 for a 1.x version, 0.17 for 0.17.x, and 0.0.3 for an exact 0.0.3. Two builds share a range exactly when offer rule 3 draws the same caret boundary for both. The registry derives the range; a client never sends it. One mikro toolchain release compiles against exactly one (firmwareVersion, bytecodeVersion) pair, so the range also identifies the toolchain line a build came from; bytecodeVersion stays on the build record as metadata and as the sanity gate in offer rule 1.

Releases are immutable. Publishing the same (app, version, firmwareRange) again with the same checksum is a success (a 2xx), so a CI retry is safe. Publishing it with a different checksum is a 409. Keying on the range rather than on bytecodeVersion is what lets one version carry a variant per firmware line: two toolchains, say mikro 0.16 and 0.17, often share a bytecode version, and a bytecode key would reject the second publish as a conflicting re-publish, blocking exactly the transition the variant mechanism exists for. On any failure, respond with a non-2xx status and a text body; the CLI shows the status and the body to the user.

A successful publish or release response is JSON and MAY carry warnings, an array of strings the CLI prints verbatim, one line each. This is where the advisory reports under Schema changes between releases travel; a registry with nothing to say omits the field.

The config schema. An app that takes operator-editable config declares a schema for it, and pack serializes that schema into mikro.app.json as configSchema. push then sends it as the configSchema part the same way the other manifest fields travel: the manifest is the record, and the part spares the registry from unpacking the tarball. A registry that implements config sync stores it; one that does not MUST ignore it, as with the source fields.

The serialized form is the mikro/schema AST as JSON: plain nodes discriminated by kind, the structural fields of the schema language, and annotations carried as extra properties on a node.

Annotations divide in two, and the division is normative. Display annotations (title, description, mask) never change what validates: ignore any you do not recognize, which is how new ones are added. Constraints (min, max, integer, minLength, maxLength, minItems, maxItems, format, unit) do change what validates, and you MUST NOT ignore one you do not recognize: doing so means accepting a value the author ruled out. Reject an unrecognized format or unit at publish, where whoever can fix it will see it.

Enforce every constraint you accept when validating an operator's config. The overlay PUT is the only place a constraint ever meets an operator-supplied value, because a config schema never reaches a device: whatever a device stores arrives already validated. (The reference runtime does check bounds wherever it runs, for the app's own schemas, but that is beside the point here.) The publishing toolchain sees only the author's own defaults. A registry that stores constraints without enforcing them protects nothing.

Never normalize annotation text. Compare and hash title and description exactly as authored. Schema identity is a structural comparison and the config rev is a hash. An NFKC pass rewrites the micro sign to Greek mu, and superscript digits to plain ones, so normalizing anywhere makes an unchanged republish conflict with nothing to show for it. Unit keys are ASCII precisely so this cannot bite them.

Reject with a 400, at publish, what config sync could never serve: a schema containing unknown(), an optional() wrapping an object or an array (an overlay needs every absence to mean exactly one thing; see Config sync), a node kind you do not know, a default in a position where it could never apply (on an object(), on an optional() or the node it wraps, or anywhere inside an array, tuple, union, or taggedUnion, each of which is filled from its own whole-value default or not at all), an empty union or a branchless taggedUnion (unsatisfiable, so they could only fail at serve), __proto__, constructor or prototype as a field name or branch tag (writing one through out[key] reaches the prototype rather than a property), a serialized size over 32 KiB, nesting more than 8 levels deep, or a schema whose materialized defaults alone encode over the 4 KiB effective-document cap. The defaults are part of every effective document, so with defaults that big every authored config would exceed the cap and rule 5 would pause every rollout, discovered one device at a time. Publish is where the author can still rename a field or add a default; storing a schema and failing at serve time helps nobody.

Annotations count toward release identity like anything else in the schema, so correcting a typo in a description and republishing the same version is a conflict. That is deliberate: a version names one source tree. Say it in the conflict, though. Report which fields differ, and say when the only differences are annotations. Otherwise an author who changed one word gets back "different config schema" and nothing else.

The schema belongs to the release, not the build. Every variant of (app, version) is packed from the same source, so each carries the same schema, and a publish whose configSchema differs from the one already stored for (app, version) is a 409, exactly like a differing checksum. What may change between releases, and what to tell the operator when it does, is covered under Schema changes between releases.

A minimal registry may ignore create, for example by auto-creating apps or serving a single implicit app. It exists so that a multi-app registry can refuse to start a new app lineage from a typo in the app name.

Cap request bodies. Publish is the only route that carries more than a few kilobytes. A registry that buffers bodies has to enforce the limit while reading and answer 413 once it is passed, not after routing, so that an unauthenticated POST cannot exhaust memory. The reference serve adapter defaults to 16 MiB (maxBodyBytes). The reference registry also caps its own routes, since a bare {fetch} export deployed to another host has no adapter ceiling at all: 16 KiB for the authenticated JSON and CBOR routes (enroll, release, the device config write, and check-in) and 64 KiB for the unauthenticated login routes, each answering 413 once passed.

Pointing a channel at an already-stored build. mikro ota release uses this to serve a build that is already uploaded. POST /releases, JSON body {app, version, channel}, Authorization: Bearer <token>, app-scoped exactly like publish (an app-scoped token that names another app is a 403). It points channel at every already-stored build for (app, version) (one build per firmware range, since a build pins the firmware line it was compiled against), and responds {ok: true, released: <count>}. It answers 404 when no such build exists. channel follows the same rule as at publish. One operation covers two jobs: graduation (point a second channel at a proven build) and rollback (point a channel at an older one); the direction does not matter. The two endpoints split on upload versus serve: push --release <channel> uploads a new build and points a channel in one call; release points a channel at a build already stored.

Compatibility. Absent channel reads as main everywhere, so existing devices and stored builds keep working with no migration pass: today's fleet is "everyone on main", and a build promoted to main serves exactly as it did before channels. Two changes to the previous contract follow from decoupling storage from serving, both to be stated for anyone tracking the CLI:

  • A bare push no longer serves the build. The previous mikro ota publish uploaded and promoted in one step; that is now push --release main (or release <version> main), and a push with no channel uploads without serving.
  • The CLI command publish is renamed to push.

Re-keying from bytecodeVersion to the firmware range needs no migration for main: it is derived from promotedAt on build records, and a stored build's range derives from its firmwareVersion on read. A registry holding named-channel pointers keyed by bytecode version must re-key them, either by joining each pointer's checksum to its build and deriving the range, or by re-pointing each channel (release is idempotent). Stored builds stand as they are; the new key only changes which future publishes conflict.

3. The offer object and the download ​

The device passes a check-in response body straight to ota.settle(). Anything that is not a valid offer is read as "no update": an empty body, null, {}, or a 204 all mean that. A valid offer is:

FieldTypeMeaning
urlstringHTTPS URL of the .tgz build (path must end .tgz)
checksumstringSHA-256 (lowercase hex), the device verifies after download
sizenumberBuild size in bytes
versionstring, optionalThe release's app version; shown to operators, unread by devices

An offer says what to fetch and how to verify it, and nothing else. It carries no firmwareVersion or bytecodeVersion. Those fields choose which build to offer, and only the registry can act on them, because only the registry holds the other builds. A device that is handed a build it cannot run can say "not this one" but never "give me that one instead", so the choice belongs entirely to the registry (see Offer selection). A registry that sends the extra fields anyway does no harm: the device ignores fields it does not know.

Because the choice sits with the registry, a custom registry has to implement the offer rules or its devices will be handed builds they cannot run. A device does not decline such a build quietly. It installs it, the install fails, the trial rolls it back, and the next check-in reports the failure through lastInstall, which takes the build off that device's list. That recovery works, but it costs a download and a failed install each time, so it is not a substitute for choosing correctly.

The device rejects an offer whose url path is not https://….tgz, whose checksum is empty, or whose size is not a positive integer; ota.settle() and the built-in client apply the same rules. It does not check the url's host. The check-in is authenticated, so the registry is trusted to say where the build lives, including a CDN or object store on a different host. (allowInsecure lets development setups accept http.)

The download must serve the exact bytes at url. Range requests are recommended (Accept-Ranges: bytes, then 206 with Content-Range). The reference client streams the build straight to flash and resumes an interrupted download from what it already has, so a dropped connection costs only the remaining bytes. Answering 200 with the whole build is also correct; the client skips the prefix it already holds. Static storage (S3, a CDN, nginx) provides ranges for free.

Authenticating the download. The download callback is app code, so the device can attach whatever the URL needs. A registry may pick any of these:

  • The device update key, which is what the reference registry uses. The download takes the same update key as the check-in and answers 401 before revealing whether a build exists, so de-enrolling a device stops it downloading. The reference client sends the update key only when the URL is on the same origin as the registry it enrolled against, so a URL pointing elsewhere never receives it.
  • A signed URL issued at check-in: short-lived, with the signature in the query string. Nothing long-lived is sent, revocation is simply not issuing another, and it works when builds live in object storage on a different host. No device change is needed, because the URL is opaque to the device.
  • The checksum as an unauthenticated capability. Simplest, and weakest. Checksums are not secret: mikro ota push prints them and they end up in CI logs. Anyone who learns one can fetch that build forever, and a de-enrolled device keeps its access.

Whichever you choose, scope the download to the requesting device's app. Authenticating the device only proves the caller is a device. On a registry that serves more than one app, that alone would let any enrolled device fetch any other app's build using a checksum from a log.

4. The enroll endpoint (optional) ​

mikro ota enroll uses this to set a device up from a workstation. The CLI reads the hardware id off the connected device, calls the registry, and writes the returned update key back to the device. A registry that issues update keys some other way can skip this endpoint; users then provision with mikro ota enroll --update-key <secret>.

POST /devices, JSON body {deviceId, name?, app?, channel?}, response {device: {...}, credential: string}. The update key is returned exactly once, so store only a hash of it. Auth is an opaque bearer token. Any tenancy (org, project) is resolved from the token, never from the URL path, so the CLI uses one path shape against every registry.

name is optional on the wire, but mikro ota enroll always sends one. It uses an explicit --name if given, otherwise the name already on the device, otherwise a default it derives from the deviceId and writes to the device at the same time. So an enrolled device has a real name on both sides, and a registry never has to reproduce that derivation to show the same string the CLI shows (see Name sync).

app binds the device to one app lineage, the only app it will ever be offered builds for (see Offer selection). An app-scoped token binds to its own app and rejects a conflicting app with 403; an unscoped token may name any app. app is optional on the wire, but enrollment is the only place a binding is ever set, so always send it. A device enrolled without one is offered nothing until it is re-enrolled with an app.

channel puts the device on a named release channel (see Offer selection). It is optional and defaults to main, so a device enrolled without one, like every device enrolled before channels existed, is on main. The channel is registry-side state the device never learns: it checks in, and the registry offers whatever its channel points at. Only a channel other than main is stored, and its name follows the same rule as at publish (^[a-zA-Z0-9][a-zA-Z0-9._-]*$, at most 64 characters).

If the deviceId is already enrolled, respond 409. The CLI then offers POST /devices/:deviceId/key (through --re-enroll), which returns {credential}: a fresh update key that invalidates the old one the moment it is returned.

Both this endpoint and GET /devices are scoped by the token, not just gated by it. An app-scoped token lists only its own app's devices, and rotating the update key of a device in another app returns 404, not 403, so the token cannot be used to find out that other apps' devices exist.

5. Browser login (optional) ​

mikro ota setup uses this to get a token without the user pasting one. The CLI asks the registry for a login session, sends the user to a browser to log in and approve, then polls for the token. Registries with static or externally issued tokens skip this, and the CLI falls back to prompting for a token.

POST /auth/sessions, unauthenticated. The JSON body may carry context for the approval page: {app?: string}, the app lineage of the project the CLI is running in (from its package.json name). The response is served Cache-Control: no-store:

FieldTypeMeaning
loginUrlstringAbsolute URL the user opens in a browser to approve. Carries no session; the same value each time
codestringSession code, the secret used to poll; returned once
userCodestringShort code the CLI displays; the user types it into the approval page
expiresInnumberSeconds until the session expires
intervalnumber, optionalMinimum seconds between polls (default 3)

GET /auth/sessions/:code/token returns 202 while the login is pending, 200 with {token: string} exactly once when it is approved (the registry then discards the session), and 404 for an unknown, expired, denied, or already-claimed code (the CLI starts over). Served Cache-Control: no-store.

The two codes do different jobs, and both are needed. code is the secret the CLI polls with; only the CLI has it. userCode is short and easy to type, and the approval page refuses to approve until the operator types a matching one. Without userCode, the session-creation endpoint would let anyone start a session, send an operator the loginUrl, and poll for the token that the operator's approval mints. userCode is what ties the browser doing the approving back to the CLI that started the session, because only that CLI's terminal shows it.

The CLI prints userCode in its own terminal, next to loginUrl, so only the operator who started the session ever sees it. Given that, a registry must:

  • Generate userCode from an alphabet with no ambiguous characters (no vowels, no 0/O, no 1/I/L), and compare it case-insensitively, ignoring whitespace and separators, so it survives being read off one screen and typed into another. The CLI shows the code exactly as sent. The leniency is for what the operator types, not permission to reformat a value that is sent back to the registry.
  • Put nothing session-specific in loginUrl: not code, not userCode, not a per-session handle. It is a constant page, and the code the operator types is what selects the session. A URL is a poor place for a secret: it lands in server access logs, browser history, and the Referer header on anything the page links to. Putting the secret in the URL fragment only hides it from the server, not from the browser. This is RFC 8628's verification_uri. That spec also defines verification_uri_complete, with the code embedded, and warns against it for the same reasons.
  • Because the code is now the lookup key, generate one that no live session is already using.
  • Reveal nothing until the code is entered. The page should say what approving would grant (which app, what the token can do) so that a forwarded link looks suspicious. But showing that to anyone who merely opens the URL reveals which app the CLI is authorizing, so ask for the code first, then show the grant and ask for the credential.
  • Rate-limit a wrong code the same as a wrong credential, so the code cannot be used to probe for live sessions.
  • Cap how many logins can be pending at once, answering 429 with Retry-After past the cap. Starting a session is unauthenticated, so without a cap anyone can grow that state without bound. Look sessions up by an index rather than scanning them, or the unauthenticated endpoint becomes quadratic in the number of pending logins.
  • Return the same message for a wrong code and a wrong password, so neither reveals which was wrong.
  • Rate-limit approval attempts per client address and globally, with backoff. The reference implementation allows three failures per address before backing off, doubles the delay up to a minute, and forgets a count after an hour idle; it answers 429 with Retry-After while a bucket is backing off. Count an attempt when the request is admitted, not when it fails. Everything in between is asynchronous, so a limiter that counts at the end limits round trips rather than guesses, and a single burst of concurrent requests slips through.
  • Count a wrong credential against the global bucket even when the code names a live session. Starting a session is unauthenticated, so an attacker can mint their own code and guess the credential behind it. The per-address bucket alone does not stop this, because addresses are cheap and an attacker with a range of them avoids the backoff by rotating. The global bucket is what a distributed guess runs into.
  • Consult the global bucket only for a wrong credential. A correct credential, and the CLI's high-entropy code poll (GET /auth/sessions/:code/token, not the userCode a person types on the approval page), must pass whatever state the bucket is in. Otherwise a sustained guess keeps the login flow closed for every operator, and the exemption gives an attacker nothing, since using it means already knowing the credential.

Per-address limiting needs the peer address, and a fetch handler is not given one: a Request has headers, not a socket. The host adapter has to supply it. The reference Node adapter reads the connection's remote address and sets it as an internal header (mikro-client-ip), overwriting anything the client sent so it cannot be spoofed, and the registry reads the address from there. A custom adapter (a Worker, a different server) has to do the same from whatever its platform exposes, such as request.cf or a trusted X-Forwarded-For hop. Without it, every caller shares one bucket and per-address limiting does nothing. The global bucket still applies, so guessing is still bounded, but the per-address defence is only as good as the adapter makes it.

The rate-limiting rules above are the reference's realization of a smaller, model-independent invariant. State it directly if your credential model is not a shared password. There are two dimensions. A per-caller dimension (the client address here, or an account, or whatever the platform gives you) may throttle anyone. A global dimension may throttle only a caller who has not proven the secret, a wrong guess: a caller who presents the correct secret passes whatever state the global dimension is in, and clears it. Charge the per-caller dimension when a request is admitted, before the async check; charge the global dimension only on a proven wrong guess. Everything above follows from that.

Name the secret abstractly. The reference has two guessable secrets on the approval page, the short userCode and a shared password, and bounds both. If your second factor is not a guessable secret, for example when the approver is an already-authenticated session and there is no password to be "wrong", then the userCode is your only guessable secret, and the global dimension must bound its lookup: treat a wrong userCode the way this section treats a wrong password. What never needs the global bound is the CLI's code poll: that code is 128-bit, so guessing it is not a threat the global dimension defends against.

The exemption for a correct secret is safe because the global bound already makes guessing the secret infeasible, not on its own. Thirty-odd free attempts and doubling-to-a-minute backoff over a 20-bit-or-larger space is effectively unguessable, so "using the exemption means already knowing the secret" holds. Pick a small userCode space or a large free-attempt count and that premise breaks, and the exemption becomes a real hole.

One residual is intended: the secret is checked before the global bucket is consulted, so a fresh caller always gets exactly one guess before a global 429 can apply. That is fine (one guess over a large space reveals nothing) and is what lets a correct secret bypass a backing-off bucket. Do not "fix" it by moving the global check to admission, which would break that exemption. This is the same reasoning as RFC 8628 §5.1's guidance on brute-forcing the user code.

Browser login mints a single token, not one per operation, and it has to authorize everything the CLI does over a connection: publishing and releasing builds (§2) and enrolling devices (§4). The CLI never parses it, like every other token here. The app context lets the registry scope it to one project: a token good for publishing that one app and enrolling that app's devices, rather than inheriting the approving user's full authority.

Tokens expire and can be revoked. A minted token carries an expiry (the reference implementation uses 90 days) and is refused past it. A stored token with no readable expiry is treated as expired, not as eternal. Record a coarse last-used time so an operator can see which tokens are still live. The reference implementation exposes these to the registry secret only (a minted token gets 403):

  • GET /auth/tokens, returning {tokens: [{tokenHash, app?, createdAt, expiresAt, lastUsedAt?}]}. It returns hashes, never tokens: the hash is how a token is named after it has been handed out.
  • DELETE /auth/tokens/:tokenHash, returning 200, or 404 if there is no such token. The token stops working immediately.

Because a token can be revoked, the CLI treats a 401 on a token that was working as "this token is gone" and points the user back at mikro ota setup, rather than retrying it as a temporary failure.

Discovery is the endpoint itself. mikro ota setup sends POST /auth/sessions, and a 404 or 405 means the registry does not support browser login. No 401 anywhere needs a special shape: a rejected command points the user at mikro ota setup, which finds out again whether login is supported on its next run.

Security notes. The code is the only thing needed to claim the token, so it must be high-entropy (128 bits or more), short-lived (minutes), and known only to the CLI that started the session. The login page should show what is being authorized (which registry, and that a CLI requested it) so that a forwarded loginUrl looks suspicious to the person approving. The page must not be cached (Cache-Control: no-store). Compare submitted secrets in constant time, or by comparing digests of both sides rather than the secrets directly.

Advisory: the reference check-in protocol ​

The device side of a check-in is application code, so none of this section binds you. It is what the reference client and the reference registry speak, and what the hosted mikro-registry builds on. Replace it freely, and keep the required half.

Nothing in the fixed Mikro.js code sends the check-in request: the CLI and the runtime never call /checkin. Only the reference examples/ota/app/updates.ts, a file you copy and own, does. The runtime gives you the pieces around it: ota.report(), ota.settle(), ota.applyOffer (with a download callback you write), ota.bearer(), ota.registry(), and ota.reconcile(). How the device asks a server what to run is up to you. Reasons to write your own instead of adopting this one:

  • A different transport. This is an HTTPS POST. Devices on MQTT, CoAP (Thread or another mesh), BLE, or LoRaWAN run the "is there an update?" exchange over that instead. applyOffer takes bytes from any link.
  • An existing device backend. A fleet platform that already authenticates devices and signals firmware (your own, AWS IoT, Balena, and so on) can drive OTA directly. It names a build as {url, checksum, size}, the app builds an Offer from that, and calls applyOffer. There is no second protocol to stand up.
  • Push instead of poll. This polls at startup. A backend that pushes over a live connection skips the check-in and calls applyOffer when it has something.
  • A different auth model. This uses a bearer update key issued at enrollment. Mutual TLS, signed JWTs, or a hardware security module would replace it. Enrollment (§4) is itself optional, so a fleet can provision update keys its own way.
  • Side-channel delivery. Builds from an SD card, a UART gateway, or pinned into the app need no server at all. Get the bytes, and verify the checksum through applyOffer.

Adopt the reference when you are building a plain registry server for WiFi devices and want the examples/ota flow to work against it unchanged. That is the common case, and why it is the reference.

POST /checkin ​

Auth is Authorization: Bearer <device update key> (from ota.bearer()), issued at enrollment. Update keys never travel in check-in responses. A 401 means the update key no longer works (it was rotated, or the device was deleted), and the device needs to be re-enrolled at a workstation. Devices treat only a 401 this way; a temporary error keeps the update key and the normal cadence. There is no fallback secret and no pending state.

The body is one map, encoded as JSON (content-type: application/json) or CBOR (content-type: application/cbor), and a registry answers a 200 in the encoding the request declared. A registry must accept both: the built-in device client (mikro/ota/client) speaks CBOR only, while older firmware and hand-rolled check-ins speak JSON. A registry that does not understand CBOR answers 415, which the device reports as "upgrade the registry"; it never falls back to JSON. Error bodies may stay JSON regardless of the request encoding; devices act on the status code alone and do not parse them. In both encodings an optional field is omitted entirely, never sent as null/undefined: an absent free means "no figure to report".

Because the client ships in firmware, the shape below is frozen per firmware release: a registry must keep accepting the check-ins of every firmware generation still in its fleet. Extend the wire by adding optional fields, never by renaming or repurposing existing ones.

Request body:

FieldTypeMeaning
deviceIdstringHardware-derived id the device reports about itself
firmwarestring (semver)Firmware version
firmwareHashstringFirmware build hash
bytecodeintegerBytecode version the device can load
boardstring, optionalBoard name of the firmware build (e.g. esp32c6-generic)
runningmap {checksum?, version?, trial}The build executing right now
lastInstallmap {reason, detail?}, optionalDiagnostic from a failed install, sent once
lastDeclinemap {checksum, reason, detail?}, optionalWhy the last offered build was not taken, sent once (see Declined offers)
name[rev, name?], optionalThe device's name and revision (see Name sync)
freeinteger, optionalBytes free to download and stage one build
configRevstring, optionalToken of the config the device holds, or of the failed document after a config rollback (see Config sync)
configErrormap {rev, message, path?}, optionalA client that validates on device rejected the held config (see Config sync)

running is read from the live app. A device that has only staged a build still reports its previous build until the new one is actually executing, so running is never a guess.

Every field here is untrusted input that ends up in a stored record, so validate the shapes and cap the lengths rather than storing whatever arrives. The reference implementation answers 400 with {"error": "Invalid <field>"} for a malformed field instead of dropping it silently. It requires running.checksum to match ^[0-9a-f]{64}$; running.version and firmware to be at most 64 characters; bytecode to be an integer; board to be at most 64 characters and match the board-name format below; lastInstall to be exactly {reason, detail?} with reason at most 64 characters and detail at most 256, with unknown keys dropped; lastDecline to be exactly {checksum, reason, detail?} with the same caps as lastInstall and a checksum matching ^[0-9a-f]{64}$; name at most 64 characters; configRev at most 64 characters; and configError to be exactly {rev, message, path?} with rev at most 64, message at most 256, path at most 64, and unknown keys dropped. deviceId at enrollment is capped at 128 characters. A 400 is not a 401, so the device keeps its update key and its normal cadence.

Board names. board names the board the firmware was built for: lowercase, matching ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$. A generic per-chip build reports <chip>-generic (e.g. esp32c6-generic). + is not part of a board name: a registry answers 400 to it. A check-in without board is firmware that predates the field, and stays valid. The Mikro.js firmware build accepts a name of at most 47 characters, because the device keeps it in a 48-byte buffer.

free stops a registry offering a build the device has no room for. Without it, that failure shows up only once the download is under way and the staging write hits a full filesystem. Implement it unless you have a reason not to.

It is still optional on the wire, because a device may replace this whole protocol, so a registry has to behave sensibly when free never arrives. A device that has never reported free is not size-gated at all. A check-in that omits free leaves the last reported value in place rather than clearing it, so the gate keeps using the most recent report.

A registry that uses free should offer a build only when its size fits, and withhold the offer entirely rather than offering a smaller build. The device is asking for the newest build it can hold, not the largest one that happens to fit. The value is the bytes free where the build is downloaded and staged, which the reference client reads from the app filesystem (storageUsage().free in mikro/sys).

The response is the offer object from the required half, or nothing, plus the optional name and config fields described in the sections below. The response may carry further top-level fields, and devices ignore fields they do not know. That is how extensions are added; config sync itself arrived through this seam.

Identity ​

The update key is the identity. Key device records by the hash of the update key, and treat the self-reported deviceId as a label chosen at enrollment.

The display name is editable from both ends: renamed in the registry, or over the cable with mikro name set. So it is synchronized between the two sides rather than owned by one.

Enrollment always sets a name (§4), so a registry only ever sees devices that have one, and shows it as stored. The id-derived default is a seed, used once at enrollment, not a shared algorithm. No registry has to reproduce it, and changing it can never silently rename devices that already exist. An unenrolled device may have no stored name, in which case the CLI derives one for display on the spot.

Because the enroll endpoint carries no revision, a freshly enrolled device holds its name at revision 1 while the registry has it at revision 0. The first check-in reconciles that under rule 1 below, with no visible effect. If the name was edited in the registry before that first check-in, it reconciles under rule 4 instead, and the registry's edit wins, which is what you want.

Name sync ​

A wall-clock timestamp cannot decide a rename, because a device may have no valid time before it reaches an NTP server. So each side stores the name with a logical revision, and every deliberate rename adds one to it. Both the check-in request and its response carry the name and revision as one field:

"name": [rev, name]     // named, at revision `rev`
"name": [rev]           // name cleared at revision `rev`
                        // field omitted entirely = no change, or not supported

The name and revision travel together, so a name can never be seen at the wrong revision. Clearing a name is a normal write with a higher revision, so a deletion propagates instead of the old name coming back.

Given the device's name and revision at check-in, the registry compares revisions:

CaseRegistryResponse
1. Device revision higheradopts the device's pairno name field
2. Registry revision higherkeeps its ownits [rev, name]
3. Equal revisions, same namein sync, nothing happensno name field
4a. Equal revisions, registry unnamedadopts the device's nameno name field
4b. Equal revisions, different namesregistry wins, stores its name at rev + 1[rev + 1, name]

Rule 4b is the concurrent-edit case: both sides renamed the device before either synchronized. The registry wins so that the two sides converge and do not flip back and forth. The loss is visible: whoever renamed the device at the serial port sees it change back, and can redo the rename, which then wins at a higher revision.

Rule 4a tells "never named" apart from "deliberately cleared", and the revision is what distinguishes them. A registry that cleared a name has a revision recording that it did, while one that never had a name sits at revision 0. Without this split, a device enrolled without a name would meet its own seed name at revision 0, which reads as a concurrent edit and orders the device to clear the only name either side has.

A device that receives a name field adopts and stores it. A response with no name field always means "no change" and must never be read as an instruction to clear the name. A response lost after a rule-1 adoption looks the same as one that never arrived, and re-sending the same pair at the next check-in settles it as rule 3.

Compatibility: firmware that sends no name field keeps its previous behavior, so engage sync only when the field is present. A registry that stores no revision treats old records as revision 0. That leaves one accepted migration case: a device renamed over the cable before the registry learned about revisions reports revision 1 against a legacy 0, so the device's name wins that first sync even if the registry had renamed it earlier. This happens once, corrects itself, and favors whoever has the hardware in hand.

Config sync ​

An app that publishes a config schema can have remote configuration delivered to its devices with check-in responses. The device stores what it receives without understanding it, and the app reads it back through ota.config() as one typed value. All validation happens where the schema lives, in the registry, never on the device: the same parties ship the code the device runs, so a config the registry validated needs no second check. The device's only assembly step is a single top-level spread, described below. There is no second protocol: config rides the check-in, and a registry that does not implement it changes nothing else.

Overlays in storage and on the wire. What an operator authors, what the registry STORES, and what it SERVES are all deviations from the schema defaults, never the defaults themselves: derive the stored overlay by dropping unknown keys, stripping values structurally equal to the field's default, and pruning empty objects and empty arrays. The device resolves what it receives with a single top-level spread over the defaults its own build carries, {...configDefaults, ...doc}, and nothing deeper. So the served overlay is wholesale at the top level: every key present carries a COMPLETE top-level value. A deviating leaf inside a nested plain object therefore ships its whole top-level key, defaulted siblings included, and a union, tuple, tagged-union, or array value is compared to its default as one unit: equal as a whole, omitted; different in any way, present in full. The same wholesale rule governs storage, where nothing inside such a value is stripped or pruned, so empty objects and arrays inside one survive.

The wholesale rule is normative and the device cannot check it. The device holds no schema, only its defaults, so it cannot tell a complete top-level value from a pruned one; it spreads whatever arrives. A registry that prunes inside a value corrupts the config silently, and this spec is the only thing that prevents it. The case that motivates the rule: with a default {"mode": {"kind": "a", "x": 1}} and an effective config {"mode": {"kind": "b", "x": 1}}, the wire MUST carry {"mode": {"kind": "b", "x": 1}} in full, the equal-looking x included. The x under branch b is a different schema node than the x under branch a, so "equal to the default" is not defined across them, and dropping it leaves the device spreading branch a's field into branch b's value. No layer anywhere does a shape-directed deep merge.

A complete effective document is itself a valid overlay: every key is present, and spreading it over the defaults yields it verbatim. A registry that serves complete documents is therefore still correct, at the cost of wire bytes and of the device's stored copy. The reverse does not hold, so a device that expects a complete document reads an overlay as the whole config.

Storing overlays is what lets defaults evolve: when a new release changes a default, every device without an override picks it up by installing the build (the new defaults ride in the build's own manifest as configDefaults), with no config write anywhere, and the next serve against the new release recomputes the deviations against those defaults, so what a device holds stays limited to what an operator actually set. Two consequences are worth stating to operators rather than discovering. Setting a field to its default is the same as never setting it, so nothing can pin today's default against a future release changing it. And clearing a field, which for a list means removing every item, falls back to the default rather than storing an empty value; emptiness is never a deliberate state in an overlay.

The response field.

"config": {"rev": "<opaque>", "version": "1.4.0", "doc": {…deviations from the defaults…}}

rev is an opaque token, at most 64 characters, that identifies the EFFECTIVE config this device should hold, not the overlay that expresses it. The registry defines it (a hash of the version and the key-sorted effective document works, and so does a revision counter over that document); the device never computes one, it stores the token and echoes it verbatim as configRev on later check-ins. Two overlays that resolve to the same effective config MUST share a rev. That is what keeps an edit which lands a value back on its default, changing the overlay and nothing else, from redelivering a config the device already runs. version names the release whose schema the config was computed against, and the device refuses to surface a document stamped for a version other than the one actually running. doc is the deviation overlay: the top-level entries of the effective config that differ from that release's defaults, each complete, and the device resolves {...configDefaults, ...doc} when the app reads it. Values travel as they were authored, on the wire, in registry storage, and in the device's NVS copy. Send a doc only when at least one top-level value deviates: a device whose config is the defaults holds no document at all and runs on its own manifest's configDefaults, so bare defaults never travel. Sending config with no doc key is the deliberate clear: the device deletes its stored document, falls back to its manifest defaults, and stops echoing configRev. The absent doc key alone is what the device keys the clear on; a rev riding along is ignored, so a clear SHOULD omit it. Omitting the config field entirely means "no change", so the omit-never-null rule holds.

When to send. Send config whenever the configRev the device echoed is not the token of what the registry currently wants it to have, including when no configRev arrived at all. No field announces whether the firmware supports config sync: firmware that does stores the document and echoes the token, which stops the sending; firmware that predates it ignores the unknown field, and the registry re-sends a few hundred bytes per check-in to no effect, which is harmless and disappears when the fleet's firmware catches up.

Which version the config is for. A response that carries an offer carries the config for the offered release, staged with the build and applied together with it, so the new version boots its trial with the config that matches it. A response without an offer carries the config for the running release. config.version states the choice either way. During a trial the running build is the trial build, so a check-in made mid-trial is answered against the trial's release; if the trial rolls back, the reference client restores the previous document together with the previous build and echoes its token again, and the next check-in reconciles as usual. One edit therefore waits: a change to the running release's config while an offer is pending is not delivered until the offer settles, and self-heals at the first check-in after adopt or rollback.

Config trials. A document delivered for the running release goes on trial on the device, exactly as an offered build does: the previous document is kept as the rollback baseline, and the trial is adopted at the next completed check-in after the app has read the new document. A schema-valid value can still be fatal to the app (a GPIO the board does not have), and the crash it causes can fire before any check-in runs, so the device accounts the trial in ota.config() itself: each boot that reads config burns one trial boot, and when the budget is spent the read restores the previous document, which is what breaks a crash loop. After a rollback the device keeps echoing the FAILED document's rev as configRev, alongside a configError report. A registry needs no logic for any of this: echoed-equals-expected already stops it re-serving the failed document, and the next document it serves, after an operator changes the config, starts a fresh trial. A document staged with an offer needs no separate trial; it rides the build's.

Serving safely. Never send a document that does not validate; withhold it and surface the failure to the operator instead, the same posture the free gate takes toward builds. The device applies whatever arrives without checking it: validation is entirely the registry's responsibility, which is why it MUST happen on every serve. The stored overlay is adapted to the target release per serve, as a pure function: drop keys the target schema does not know (from the wire, not from storage), strip values equal to the target schema's defaults, prune empty containers, merge the defaults in, validate the merged document, and serve the top-level entries of that document which deviate from the target's defaults. Stored overlays are never rewritten to fit a newer schema. A fleet runs many releases at once and a rollback can resurrect an old one, so the same stored overlay must keep serving every release the device might run, and each serve derives its own view. Cap the EFFECTIVE document at 4 KiB encoded, where it is computed: at authoring time and at every serve. That document is what the device materializes by spreading the overlay over its defaults, so the cap bounds its heap, and it also bounds the overlay, which is a subset of the document's top-level entries. The device stages the overlay in NVS and parses it from the check-in response buffer.

Which defaults a device runs on. The defaults follow from the app archive, and the check-in's running.checksum identifies that archive exactly. A registry MAY therefore treat a running checksum matching none of the release's builds as a device running defaults it has never seen, and withhold or flag config sync for it. An archive rebuilt under an unchanged version is exactly this case.

configError. A device report that a registry SHOULD accept and show to the operator: rev names a document, and message (with an optional path) says what happened to it. The built-in client sends it after a config trial rolls back; hand-rolled clients that validate on device may also use it. It stands until a new document is delivered or the config is cleared. It needs no automatic reaction, since the rev echo already prevents re-serving the failed document; its job is to tell the operator why the value they set is not in effect.

Schema changes between releases. Diff the incoming schema against the app's latest release at publish and warn in the response body; the CLI shows it while the author can still act on it. Three kinds of change exist. Safe: a new defaulted or optional field, a new array or a new object of such fields, loosening required to defaulted or optional, adding a union member, loosening a bound (raising a max, lowering a min, or dropping either), dropping a format, and any change to title, description or mask. Needs an operator: a new required field, tightening to required, changing a kept field's type, removing a union member that stored overlays still use, tightening or adding a bound, adding or changing a format, and changing a unit. A device affected by one of those is not offered the new release until someone supplies or fixes the value (offer rule 5). Informational: a changed default alters behavior on every device without an override for it, and a removed field leaves its stored overrides in place, no longer served.

A changed unit is the one entry on that list no validation catches. 30 is valid whether the unit is s or ms, so nothing fails and every stored value quietly means something else. Gate it and say which unit it was.

Count union members by shape when deciding whether one was removed. Two members can differ only in their bounds, so a member of that shape surviving does not mean none was lost: dropping one of union([number({max: 10}), number({min: 100})]) strands any override only the removed range accepted. Compare bounds within members separately from whether a member is still there, or a merely loosened bound reads as a removal and gates a release that widened what validates. At release time, report against the actual fleet: which devices on the channel hold overlays that will not validate under the new release, before anything is served. One drift no machinery catches: a field that keeps its name and type while its meaning changes validates everywhere and misbehaves everywhere. Change the key name when the meaning changes.

Out of scope. How operators author config is the registry's own business: a dashboard form rendered from the schema, an API, per-fleet defaults layered under per-device values, anything. The wire defines delivery only, and nothing in it changes if the authoring model does.

Config values are plaintext end to end: an operator-facing read returns what is stored, the check-in response carries it in the clear inside TLS, and the device holds it as NVS plaintext. The device-side docs state that as a property of config rather than leaving it a surprise. Credentials belong in env vars, which are set over the cable and never travel through a registry. A required field with no default is the tool for a value an operator must supply: until one is set, rule 5 withholds the release from that device.

Offer selection ​

The app is the device's, never the check-in's. A device is bound to one app lineage at enrollment (§4), and that binding decides which app's builds it may be offered. running.checksum is a status report, not a routing input. Deriving the app from it would let any enrolled device pull any app's build by naming that app's checksum. A registry that sees a device reporting a build that belongs to another app should log the mismatch and keep offering only the device's own app.

The same holds for a device with no binding yet (an older enrollment, or an unscoped token that named no app). An unbound device is offered nothing and stays unbound. A registry must not infer the binding on the first check-in: not from the build the device reports running (as above), and not from being the only app on the registry (true today, false the moment a second app is published, and the devices bound to the first keep that binding). Withholding is the safe answer to a binding that no one has made.

Bind at enrollment. It is the one point where the operator says which app a device belongs to, and the recommended pattern is to always name it: pass app, or enroll with an app-scoped token, which supplies its own. A registry does not have to refuse an enrollment without one, and this one does not. Whether an unbound device is an error or a device awaiting a decision is the operator's call, not the registry's. The only consequence is that it receives nothing until it is re-enrolled with an app.

Keep one current build per (app, channel, firmwareRange); pointing a channel at a build makes it that channel's current build for the build's firmware range. A device follows one channel (device.channel, set at enrollment, absent reads as main), and its current build is whatever that channel points at. For a named channel that is the (app, channel, firmwareRange) pointer. For main it is the pre-channels current build: the highest-promotedAt build for (app, firmwareRange) among those that have a promotedAt, so a build stored by a bare push, which has none, is never main's current. Track "current" explicitly rather than deriving it from a creation timestamp, and give each channel's pointer a total order, so that two writes in the same millisecond cannot leave "current" ambiguous. Pointing a channel at an already-stored build must work too, not only a fresh push: that is how a fleet graduates a proven build or rolls back to an earlier one, and the pointer still moves even though the write is otherwise idempotent.

Given a check-in, offer the current build only when all of these hold:

  1. The device is bound to an app, its reported firmware derives a firmware range (a version that is not valid semver derives none and withholds the offer rather than widening the gate), a current build exists for that app and the device's channel at that range, and the build's bytecodeVersion equals the device's bytecode. The bytecode check is a sanity gate, not the compatibility key: one toolchain produces one (firmwareVersion, bytecodeVersion) pair, so the range already implies the bytecode, and a mismatch means a firmware whose version string over-promises. If no build matches, offer nothing: the device needs a firmware reflash first, which happens out of band.
  2. build.checksum != running.checksum, so you do not offer what is already running.
  3. device.firmware satisfies ^build.firmwareVersion (npm caret semantics): at or above the build's firmware version, within its left-most non-zero segment. That is the major for 1.x (^1.3.0 serves 1.9, not 2.0) and the minor for 0.x (^0.16.0 serves 0.16.x, not 0.17). During 0.x, semver treats a minor bump as breaking, so a plain "same major" check does nothing there, since every 0.x version shares major 0. The caret boundary is where breaking changes are, which is why variants and channel pointers key on the firmware range: bytecodeVersion does not cover them, because it tracks the engine, and a release can remove a mikro/* API without changing QuickJS. Rule 1's range lookup fixes the breaking boundary; this rule enforces the lower bound within it, at or above the build's firmwareVersion.
  4. The checksum has not failed on this device before. lastInstall reports failures, and the device also refuses to re-download a build it has abandoned.
  5. When the build's release carries a config schema, the device's config for that release validates. The offer and its config are a pair, and a device is never booted into a release whose config cannot exist. A schema that adds a required field therefore pauses each affected device's rollout, visibly, until an operator supplies the value; the release-time report under Config sync is what makes the pause visible before anyone wonders why nothing is updating.

The device record is what these are read from, so, as with free, the most recent reported firmware and bytecode stand when a check-in leaves them out.

Failures must not be permanent. A build can fail for a reason that is later fixed (a full filesystem, a bad network), and a device whose failure list grows forever eventually refuses every build. So bound the list (the reference implementation keeps the 16 most recent) and give operators a way to clear it: DELETE /devices/:deviceId/failures, returning 200, scoped by the token like the other device routes (404 outside its app).

Success tracking ​

Record running and lastInstall on every check-in. An update succeeded when a later check-in reports running.checksum == offeredChecksum with running.trial == false. A download, a staged build, or a build still on trial is not success. This is why trial is required whenever running is sent: if it is left to stand from a previous check-in, it never turns false, and the update never reads as having landed.

Clear the recorded offer once it succeeds. lastInstall carries no checksum, so a failure report is attributed to whatever was offered last. If a settled offer stays on record, an unrelated later diagnostic marks the build the device is running as failed, and rule 4 above then withholds the one build known to work on it.

Declined offers ​

A device that is offered a build does not always take it, and when it stops trying, nothing about running changes, so a registry that waits for running.checksum to turn into the offered checksum waits forever, showing an update as pending that will never arrive. lastDecline is how the device says so. It carries the checksum it declined, unlike lastInstall, so it needs no attribution against the last offer.

reason is one of:

reasonmeaningretried?
exhaustedRepeated download failures spent the device's retry budget for this buildYes, after a reboot
abandonedThe bytes failed verification, so they can never succeedNo, not on this device
download-failedOne download attempt failed; the budget still has roomYes, at the next check-in
install-failedStaging or verification failedDepends on the kind; see detail

Treat exhausted and download-failed as "not yet", not as a build that is broken: a device on poor wifi produces both, and the build may be fine everywhere else. Only abandoned is evidence about the bytes, and it is the one that belongs in failedChecksums.

Sent once, on the first check-in after the decline, and dropped on reboot if it was never delivered. A registry that never sees one has learned nothing either way.

Suggested data model ​

  • builds: checksum (primary key), app, version, bytecodeVersion, firmwareVersion, size, storageRef, createdAt, promotedAt (the highest promotedAt per (app, firmwareRange) is main's current build; the range derives from firmwareVersion, so whether to store it is an implementation choice), and optional source fields repository, directory, commit, dirty (see the publish table). Records stay tag-less, so one build can be served on more than one channel.
  • channels: (app, channel, firmwareRange) (primary key), checksum. A movable pointer to a build; the key is unique, so a release overwrites it and there is nothing to order. main is not stored here; it is the highest-promotedAt build (see builds).
  • devices: deviceId (primary key), updateKeyHash, app (the binding, set at enrollment, nullable), channel (the release channel, set at enrollment, nullable, absent reads as main), name (nullable), nameRev (the name's logical revision, absent reads as 0), lastSeen, runningChecksum, runningVersion, trial, lastFirmware, lastBytecode, lastFree, lastInstall, lastDecline, lastOfferedChecksum (cleared once the offer succeeds), failedChecksums (bounded), and, with config sync, configOverrides (the stored overlay), configAuthoredFor (the version whose schema it was last saved against), lastConfigRev, configError.
  • configSchemas: (app, version) (primary key), schema. Release-level, shared by every firmware-range variant of the version; the publish 409 above is what keeps it single.
  • tokens: tokenHash (primary key), app (nullable), createdAt, expiresAt, lastUsedAt.

File-backed storage with no database is enough for a reference implementation. Records are keyed by untrusted strings (device ids, token hashes), so a store that indexes them into a plain object must use a null-prototype object, or Object.hasOwn guards. Otherwise constructor and __proto__ read as existing entries, and writing them corrupts the index.

Keep internal errors in the server log, not the response. A 500 body that echoes an exception message can hand out filesystem paths. Return a fixed message instead.

Non-goals ​

  • Producing builds (the CLI does this).
  • OTA of the firmware binary (app builds only).
  • Any device-side logic; the device and the CLI are implemented in the Mikro.js repo.

Accepted risks ​

Two properties follow from the design above. Both are deliberate, and implementers should know they are choices, not oversights.

Builds are not signed. Integrity rests on the SHA-256 in the offer, which arrives in the same response as the URL, so a registry that answers a check-in decides what the device runs. TLS is what keeps that authority with the real registry, which is why §3 requires https. The consequence is that compromising a registry means running code on the whole fleet. Recovery may need physical access: a cleaned-up registry can push a good build over the air, but only if the build already running still runs the update cycle at all. Signing builds against a key burned in at flash time would separate "who serves the bytes" from "who may authorize code", and reduce a registry compromise to a denial of service. It is not part of this version.

The registry does not block downgrades. This is about the registry, not the device: the firmware still rolls back a build that fails its trial. What a registry does not do is stop a deliberate downgrade. Offer selection follows whichever build a channel points at, and pointing a channel (push --release or release) is what sets a channel's "current", so releasing an older build is itself the downgrade. The registry never checks whether an offer is older than what a device is running. A publisher can therefore move a fleet to any earlier build, including one with a known defect. Registries that need to prevent this should refuse to release a version below a channel's current one, or track a per-channel counter that only moves forward.

References ​

Mikro.js is built with AI assistance, code and docs alike. Read the AI disclosure.