(internal) api: access log lines of requests authenticated with a JWT now carry accountId β the ACL middleware fills only req.identity on that path, so the log resolves the account and organization from the identity when req.account / req.organization are not set
api: PUT /v1/device/{deviceUid}/deprovision requires write_device on the device again instead of the generic update action β the ACL change shipped in 54.1.0 locked out every account whose role grants device management without organization-level write (e.g. role User), returning 404 on deprovision (CU-86cag9cww)
(internal) types: the firmware usage verdict no longer throws on stored device versions outside its declared type β legacy devices reported numeric versions, which crashed the firmware usage report and GET /firmware/usage-counts with a 500. Numeric versions are compared after coercion, other malformed values block as the version-not-normalizable verdict, and a missing version reads as never-reported
types: firmwareVersionReferencedBy in Firmware/firmwareVersionUsage β fail-safe verdict (referenced / unreferenced / unknown + reason) deciding whether a version-string reference points at a firmware version record, using the platform's numericalVersionsEqual comparison; undecidable references are surfaced instead of guessed
user-domain-model: createFirmwareVersionUsageModel β firmware version usage report across installed devices, unresolved SET_FIRMWARE_VERSION actions, policies and pending bulk operations, with per-class counts, a per-reason unknown breakdown and sample uids; safeToDelete only when nothing is referenced and nothing is undecidable. The factory requires the bulk-operation connection explicitly and scans the action log in bounded batches; supporting indexes added on the scanned collections
commands/events/command-handler: firmware version deletion lifecycle β Firmware.RequestFirmwareVersionDeletion, Firmware.CancelFirmwareVersionDeletion and Firmware.FinalizeFirmwareVersionDeletion with a caller-attested usageSnapshot (validated for freshness and consistency; total === 0 unless forced; persisted into FirmwareVersionDeleted for audit). A pending deletion blocks publishing and device firmware assignment; a deleted record reads as missing everywhere and frees its (version, firmwareType, osVersion) identity
commands/events/command-handler/platform-consumer: firmware version identity edit β Firmware.UpdateFirmwareVersionIdentity edits version/semverVersion/firmwareType/brand/osVersion on an unpublished, non-pending record behind the same usage gate; osVersion can be set or changed but never cleared, brand cannot become LG without one, uniqueness is validated against the live catalog, and the legacy (applicationType, version) resolution follows renames. The edit runs inside a leased identity transition (Firmware.StartFirmwareVersionIdentityTransition / Completeβ¦ / Cancelβ¦) that closes the version to new references β device assignment, publishing, metadata changes and deletion requests reject while it is active β so the attested usage snapshot cannot be raced; a crashed dispatcher's transition expires on its own. Firmware.LinkSupersedingFirmwareVersion records the copy-on-write chain
user-domain-model/platform-consumer: firmware version deletion projection β deletionRequestedAt/deletionRequestedBy while a deletion is pending, supersededByUid for copy-on-write edits, hard delete on FirmwareVersionDeleted; every firmware projection is replay-safe after the hard delete
commands/command-handler: the firmware typed error classes and their deprecated registerErrorClass registrations are removed β aggregates reject with a plain CommandRejectedError carrying type constants from Firmware/firmwareCommands; the wire contract and API responses are unchanged
Both sort directions of API listings now produce a consistent order β ascending is the exact reverse of descending
user-domain-model: prepareFirmwareVersionTable also creates the version_firmwareType_osVersion_unique index (idempotent with the 2026-07-16 migration), so a fresh database gets the uniqueness guarantee; a legacy database with unresolved duplicates fails preparation loudly
types: DeviceForceDeprovision value in CompanyFeatureEntitlements (CU-86cag9cww)
api: New DeviceForceDeprovision company feature entitlement β a key-account-only gate for the destructive cross-tenant deprovision-by-authHash operation; POST /v1/device/deprovision returns 402 when the caller's company lacks it. Company-level because the use case spans all organizations of a company. The request body stays authHash-only; owned devices are deprovisioned via the ACL-guarded PUT /v1/device/{deviceUid}/deprovision (CU-86cag9cww)
api: authHash in the POST /v1/device/deprovision body now requires a minimum length (CU-86cag9cww)
api: PUT /v1/device/{deviceUid}/deprovision authorization goes through the standard checkPermission (Action.Write on the device) instead of the deprecated aclProvider.can with WriteDevice (CU-86cag9cww)
api: the DeprovisionDevice command originator records the calling identity (via originatorFromIdentity) instead of the device's organization, so the action log attributes who performed the deprovision β relevant for cross-tenant force deprovisions (CU-86cag9cww)
api: GET /v1/bulk-provisioning/recipe/count β count of provisioning recipes visible to the caller, using the same filters as the list endpoint
api: GET /v1/bulk-provisioning/recipe now supports the companyUid and organizationUids filters (in addition to the existing recipe filters), and lastId cursor pagination
user-domain-model: Loki.connect() throws a LokiConnectionError naming the host when Loki is unreachable or not ready, retrying until its timeout (60s by default)
api: an unreachable Loki fails the service on startup, next to the Mongo, Redis and AMQP connections
api: GET /v1/applet/{appletUid}/version supports case-insensitive version search and semantic-version ordering through search, sortKey=version and descending, returning the newest version first by default
api: GET /v1/device/{deviceUid}/report and GET /v1/device/{deviceUid}/report/count (data-reporter CSV report files) are deprecated and will be removed. Use GET /v1/device/{deviceUid}/telemetry/ONLINE_STATUS telemetry history or an Event Stream exporter subscribed to Device.DeviceConnection{Added,Deleted} instead
user-domain-model: invalidateAllByDevice on the latest monitoring-log models β deleting a device's stored latest telemetry is never correct, as nothing refills it but the device reporting that telemetry type again.
user-domain-model: Optimize fetchUidListByOrganizationUids by adding organizationUid, uid composed index (to save in-memory sort in mongo by uid)
api: GET /v1/organization applies the uids and organizationUid query filters consistently with the caller's organization permissions
api: Assigning the company owner role, and changing or removing a member who holds it, requires a different company owner. Management of every other company role is unchanged
Device request commands accept an optional requestUid, so a producer can record the uid that will correlate a device's result before dispatching the command
Device request commands that previously had no schema (volume, brightness, application and firmware version, time, debug, remote control, resize, test suite, timer, RM server, power status and the display family) are now validated on ingress β a payload that does not match its command type is rejected with invalid_command instead of being forwarded to the device
Creating an UPDATE_TIME bulk operation requires a non-null timezone; null was accepted before and reached devices as an invalid value
command-handler: on-demand inject aggregates producing ApplicationInjectRequested and the terminal inject events
user-domain-model: applicationInjectRequestModel read model over the applicationInjectRequest collection
platform-consumer: projects ApplicationInject{Requested,Succeeded,Failed} into applicationInjectRequestModel
api: GET /v1/device/configuration/telemetry-intervals and GET /v1/device/configuration/telemetry-intervals/count list the telemetry check intervals of every device in the organization, with cursor pagination
user-domain-model: uid, createdAt and organizationUid on deviceConfiguration, backfilled by the 2026-08-17_deviceConfiguration_uid and 2026-08-17_deviceConfiguration_organizationUid migrations. Deploying before they run breaks nothing, but until then reads by uid miss the untouched rows and the telemetry-intervals listing has no real createdAt to order by. Each field is filled on its own and only where absent, so a re-run is a no-op and a row that already has uid still gets createdAt. Both are dry by default
user-domain-model: deviceConfiguration is indexed by uid, and by organizationUid with createdAt and deviceIdentityHash, which serves the new telemetry-intervals listing. The uid index is not unique β uid is copied from the already unique deviceIdentityHash, so a unique index would add no guarantee while refusing to build until the backfill has run
user-domain-model: deviceConfigurationModel extends CRUDModel, so reads go through the generic fetchOne/fetchList/count. The write methods take a deviceIdentityHash identification instead of a whole configuration row, and create/update/updateByFilter throw β the entity still carries null fields the generic writes would drop, so createLegacy/updateLegacy have to be used until it is null-free
api: PUT /v1/device/configuration/:deviceUid/telemetry-intervals accepts whole milliseconds between 300000 and 2098800000, now documented in the specification. 300000 is the Ultra entitlement floor, the lowest any plan can use β the endpoint used to refuse everything below 3600000, the floor for an organization with no telemetry entitlement at all, so it rejected intervals Box lets the same organization set
api: debug and firmwareVersion are documented on the telemetry interval schemas; the endpoint already accepted them but the specification never listed them
api: GET/PUT /v1/device/configuration/:deviceUid/telemetry-intervals and POST /v1/device/configuration/:deviceUid/telemetry-cache/invalidate are served by the ACL router, so they accept account and JWT authentication and answer 404 instead of 403 for a device of another organization
platform-consumer: a device keeps organizationUid on its configuration in sync with the device, and the configuration is created with the organization it belongs to
user-domain-model: a deviceConfiguration row inserted by one of the legacy upserts gets a createdAt, which every one of them left out. It is required by CRUDEntity and is the sort key of the new telemetry-intervals listing, so a row written that way was ordered arbitrarily there
Event stream exporters can subscribe to Alert.AlertArchived and Alert.AlertUnarchived β emitted when an alert is resolved globally
All exported alert events (Alert.AlertDeviceAssigned, Alert.AlertDeviceUnassigned, Alert.AlertArchived, Alert.AlertUnarchived) carry alertRuleUid, so consumers can tell which alert rule fired or was resolved
Alert email notifications are sent again for alerts that were unarchived β archiving an alert corrupted its stored alert rule reference, which silently disabled the rule's notifications from then on
platform-consumer: Device history now records plugin and runner assignment changes
user-domain-model: the 2026-08-18_telemetry_backdated_online_status_repair migration repairs telemetry left corrupted by backdated online-status recovery events across all organizations within a required SINCE time window β history ordering that skewed uptime, negative offline ranges, and stale latest values in deviceMonitoringState and Redis. It is dry by default and must run only after the device-status-checker stamping fix is deployed
api: paginated GET listings only fetch one page of documents from the database instead of streaming surplus batches, making them faster
user-domain-model: devices have a compound index on organizationUid, createdAt and uid, serving organization-scoped listings like GET /v1/device/policy from an index instead of a scan
platform-consumer: ONLINE_STATUS telemetry is cached in arrival order instead of being dropped when its createdAt predates the cached record. A device that came back online within the offline threshold stayed cached as offline, because device-status-checker stamps an offline with lastAliveAt plus the threshold but a recovery with the ping's own time. Every other telemetry type keeps the ordering check, which protects the current value from telemetry a device buffered while offline
user-domain-model: the 2026-08-14_deviceMonitoringState_onlineStatus_redis_repair migration repairs the Redis ONLINE_STATUS cache of one organization (ORGANIZATION_UID) from deviceMonitoringState, for devices left stuck as offline before the fix. It is dry by default
user-domain-model: the 2026-08-05_deviceActionLogLatest_organizationUid_repair.js migration walks only the rows present when it starts, so writes during the run cannot prolong it; a re-run picks up the rows written in the meantime
api: the validator generator handles anyOf request bodies with titled variants
api: azure-event-hub exporter target on /v1/event-stream/exporter β SAS key values are never returned
api: Device.SystemLog.* event types accepted in subscribedEventTypes for azure-event-hub exporters, rejected for webhook
Event stream exporters can subscribe to Alert.AlertDeviceAssigned and Alert.AlertDeviceUnassigned β emitted once per device when an alert starts or stops being active on it, carrying the owning organization
api: exporter-config access checks resolve the organization of every requested config in a single event-stream-distributor request instead of one per config
user-domain-model: deviceActionLogLatest has a unique index on deviceIdentityHash and type
api: GET /v1/device/<action>/latest listings are served by indexes instead of collection scans and joins, cutting response times from seconds to milliseconds
api: GET /v1/device/<action>/latest pagination no longer skips or repeats rows sharing a createdAt
platform-consumer: The TAKE_SCREENSHOT device action log is created only when a device reports a failed instant screenshot, already marked as failed. Requested and successful screenshots are not logged anymore, they were flooding the action log. The 2026-08-11_deviceActionLog_remove_unfailed_take_screenshot migration deletes the already logged TAKE_SCREENSHOT entries without failedAt from deviceActionLog and deviceActionLogLatest
events: DeviceTakingInstantScreenshotFailed carries the deviceIdentityHash of the device that failed to take the screenshot
commands: registerErrorClass and getErrorClass β aggregates no longer need typed error classes to get a 400, since every process_failed response is now mapped to one with its message. Registration was also unreliable: it is a module-load side effect with no barrel, so most types never resolved in the api and fell back to the base class anyway
(internal) api/platform-consumer tests: the pbkdf2 work factors of @signageos/lib's token/password encryption are lowered test-only (tools/test/fastEncryption.cjs
(internal) tests: the four integration test suites (api, command-handler, platform-consumer, user-domain-model) run concurrently
command-handler: A defect in aggregate logic (a null dereference, an invalid date) is no longer reported as a rejected command. It is answered with an error response instead of process_failed, so the caller reports a generic server error rather than quoting the internal message back to the client, it is logged as aggregate defect, and it is counted as command_erred instead of command_failed
api: a command rejected by the command handler now always returns 400 with the rejection reason instead of a generic 500 β a process_failed response means the command was rejected, i.e. a client error, and it always carries a detailed message. Only rejections that happened to arrive as a registered typed error class were mapped before, so e.g. DELETE /v1/custom-script/{customScriptUid} on a script that still has versions (without deleteVersions) returned 500 UNEXPECTED_SERVER_EXCEPTION. Applies to every command, not just custom scripts
api: An organization member with the user role can again manage locations and device policies, provision devices and deprovision them from the device detail page
(internal) api: ContentGuard AI Helper can run on GCP Vertex AI (Gemini) as an alternative to AWS Bedrock β select with CONTENT_GUARD_AI_PROVIDER (bedrock, the default, or vertex) and configure via GCP_VERTEX_IMAGE_ANALYSIS_MODEL_ID, GCP_VERTEX_PROMPT_ANALYSIS_MODEL_ID and optional GCP_VERTEX_PROJECT/GCP_VERTEX_LOCATION (Application Default Credentials can supply them). AWS_BEDROCK_* env vars are required only when the bedrock provider is selected
api: Package and package-version CRUD endpoints now support paginated listing, creation, update, recycle-bin restore, binary upload and download, icon retrieval, publish and unpublish, and package deletion
api: Package version uploads require an explicit non-empty metadata version and return 400 Bad Request for invalid binaries; publish and unpublish transitions are idempotent
api: Package deletion returns a conflict while versions or policy references exist and otherwise moves the package to the recycle bin
types/user-domain-model/api: BLANK_SCREEN content guard item type β a managed, reference-image-free item scored on how uniform each screenshot is instead of by comparison with an uploaded image. It lives in an ordinary category, so a single category can mix BLANK_SCREEN with IMAGE and PROMPT items and one alert rule covers all three
command-handler: BLANK_SCREEN items can only be created in managed categories
commands/events: optional whitelabel branding metadata on CoreApp build requests β applicationBrandingTitle and applicationBrandingIconUrl on the BuildApplicationVersion command and application.branding (title, iconUrl) on all ApplicationVersionBuild*Requested events; command-handler maps the command fields into the produced events
events: Device.Screenshot.DeviceTakingInstantScreenshotFailed and Device.Screenshot.DeviceTakingInstantScreenshotSucceeded events reporting the instant screenshot attempt result, paired to the request by the echoed uid
events: uid on DeviceTakingInstantScreenshotRequested (with originator) identifying the instant screenshot request
commands: uid on RequestInstantScreenshot identifying the screenshot request (schema-generated when not provided)
command-handler: Pass the screenshot request uid and originator into DeviceTakingInstantScreenshotRequested
platform-consumer: Track instant screenshot requests in the device action log (TAKE_SCREENSHOT) β created on request, marked failed on DeviceTakingInstantScreenshotFailed and succeeded on DeviceTakingInstantScreenshotSucceeded, all consumed by the platform consumer on the main events connection; a response consumed before the request is retried via redelivery for up to 15 minutes before being dropped
api: Originator enrichment (createdBy/updatedBy/originator fields on list and detail endpoints) no longer issues Mongo queries with empty $in arrays, deduplicates the looked-up organization uids and account ids, and runs the organization and account lookups in parallel
user-domain-model: fetchListByUids (all models) and accountModel.fetchListByIds return immediately on empty input without querying Mongo and deduplicate their input before building the $in filter; the Memory implementation of fetchListByUids no longer returns duplicate entities for duplicated input uids
user-domain-model: createLockedDomainModel no longer registers process.once('SIGTERM'/'SIGINT') shutdown hooks at creation β they are registered lazily on the first waitForTimestamp() call and removed on close(). platform-consumer creates the model per consumed event batch (session-scoped), so the unconditional registration leaked 2 process listeners pinning the whole model graph (including the ended ClientSession) for every batch β the linear memory growth that OOMKilled the mongo-domain platform-consumer pods in production (~34 MB/min under load, 1 GiB limit reached in ~25 min on prod-us1)
api: GET /v1/device/telemetry/latest logs one line per request with phase timings (device fetch, telemetry fetch, join) and item counts
api: GET /v1/device/telemetry/latest accepts an optional repeatable types query parameter returning only the given telemetry types for each device (the full response carries all ~44 types; a single-type page is roughly 44Γ smaller)
user-domain-model: readable models' fetchList accepts optional fields (server-side projection); the returned cursor is typed as a Pick of the requested fields, so reading a field that was not projected does not compile
api: GET /v1/device/telemetry/latest built each device's telemetry list by rescanning the full telemetry array per device (O(nΓm), ~44M comparisons at 1000 devices); telemetries are now grouped by device in one pass
user-domain-model: the Redis latest-telemetry read (fetchLatestListByDevices) awaited its MGET batches one after another (44 sequential round trips at 1000 devices); the batches now run concurrently so the client pipelines them
api: GET /v1/device/telemetry/latest decoded full device documents with no server-side limit only to read a few fields; the device query now projects the mapped fields and stops at the page size
user-domain-model/commands/events: CustomScript.organizationUid is now optional β scripts without an owning organization are managed (global) signageOS scripts; a non-unique organizationUid index is added to the custom script collection
command-handler: creating, updating, deleting, or writing versions/releases of a managed custom script is restricted to admin accounts (AccessLevel.Admin); organization-owned scripts are unchanged
platform-consumer: managed CustomScriptCreated events (no organization) no longer touch any per-organization script count
api: GET/POST /v1/custom-script/managed and GET/PUT/DELETE /v1/custom-script/managed/{customScriptUid} β read managed scripts (any organization with the scripts entitlement) and create/update/delete them (admin accounts only)
api: PUT /v1/device/{deviceUid}/custom-script now also executes managed (global) scripts β any organization with the scripts entitlement can run a managed script on its own devices
api: the existing custom-script version and platform endpoints (/v1/custom-script/{uid}/version...) now support managed (global) scripts β reads are available to any organization with the scripts entitlement, writes (incl. the archive upload URL) are restricted to admin accounts
api: dev-only DANGEROUSLY_allow_static_token_admin env var (default off) grants admin identity to admin accounts authenticating with static account API tokens so the CLI can manage managed custom scripts on development environments β must never be enabled in production
api: excludeFeatureContext query parameter on GET /v1/location β when true, omits feature.properties.context (the single largest part of the payload, ~40%) from every returned location; opt-in only for backward compatibility with consumers that may read context
api: documented the real feature shape (raw persisted Mapbox geocoder result) in the location OpenAPI schema, replacing the placeholder name/amenity/popupContent keys
types: DEVSPACE_PLATFORM_FENTS lists the DevSpace platform-specific company feature entitlements (one per CoreApp build target), so consumers can share one allow-list instead of re-declaring it. Sits alongside DEFAULT_COMPANY_FEATURE_ENTITLEMENTS in Company/Company.ts. Intended for server-side authorization of self-service platform selection β every company feature entitlement outside this subset is commercial and admin-only.
api: ACL checks no longer issue one set of MongoDB queries per checked entity (N+1) β request-scoped providers derive an identity's privileges once and batch entity reads into $in queries. ACL decisions are unchanged except for the one below.
api: An admin is no longer granted access to a credit license whose document carries no companyNetworkUid (non-admins were already denied).
user-domain-model: createEventsInvalidatedCache takes any Cache instead of requiring a CacheWithWarmup, and no longer re-exposes warmup on the wrapper it returns β event invalidation is independent of warming, so a cache whose data has no enumerable source (e.g. a plain RedisCache fed only by writes) can be wrapped without faking a warmup. createEventsInvalidatedCacheFromModelReadableFactory still returns a warmable cache, so its consumers are unaffected
Duplicate sos_redis_driver_errors series with identical labels (dropped by Prometheus as "different value but same timestamp") and sos_redis_driver_connection_ready/_end counters silently not counting after the first redis error
api: Rate limiting is now enforced on device deprovision (POST /v1/device/deprovision, PUT /v1/device/:deviceUid/deprovision) and device verification (POST /v1/device/verification, GET /v1/device/verification/:deviceVerificationUid) endpoints to reduce brute-force attack surface
(internal) api: Request rate limits are now static configuration provided via the REQUEST_RATE_LIMITS env var (a JSON array of {method, route, windowInMs, requestLimit} rules) instead of the api-config MongoDB requestRateLimit collection; the api-config Mongo connection and its mongo_db_api_config_dsn env var have been removed. The config is validated at startup (invalid route patterns and sub-second windows fail the boot); route patterns are precompiled once; and the per-identity counter now uses an atomic redis INCR so concurrent bursts cannot exceed the limit
user-domain-model: organizationUid_createdAt index on the timing, location, and location recycle-bin collections β supports org-scoped list filtering with the default createdAt sort and cursor pagination
types: ExporterConfigSchema restructured into a per-target union β webhook exporters may subscribe to domain and telemetry event types only, azure-event-hub exporters to the full universe including system-log types; added getAllowedEventTypesForConfigType so consumers never reach into schema internals
commands: ExporterConfigEntitySchema, CreateExporterConfigSchema and UpdateExporterConfigSchema are per-target unions rejecting invalid config/subscribedEventTypes pairings
command-handler: UpdateExporterConfig rejects event types not allowed for the exporter's effective config type; ExporterConfigUpdated events now carry only the fields provided by the command
commands: createUpdateEntityCommandSchema strips .default() from entity fields, so a partial UpdateExporterConfig that omits enabled no longer injects enabled: true (which re-enabled disabled exporters on rename-only updates)
user-domain-model: fetchList method on ILockedDomainModel β fetches many locked domains in a single round trip (Mongo: one $in query on the domain_lockKey index; Redis: batched MGET)
platform-consumer: The lockedDomain freshness check at the end of each event batch (filterNewerUpdates) now reads the current timestamps of all lockKeys in one bulk fetchList query instead of one serial findOne per lockKey inside the batch transaction β removes up to batch-size database round trips per batch under per-device event floods
commands: account emails (CreateAccount, CreateSimpleAccount, AddAccountToCompany, AddAccountToOrganization) are normalized to lowercase at schema level β applies to producer factories and command-handler input validation, so case-variant invites resolve to the same account instead of creating duplicates
command-handler: AddAccountToCompany and AddAccountToOrganization now reject an email that already belongs to a member of the target company/organization (self-invite included) instead of silently overwriting the member's role β re-inviting an owner as guest no longer downgrades and locks out the owner; role changes must use the privilege-update commands
api: POST /v1/company/{companyUid}/member and POST /v1/organization/{organizationUid}/member now return 409 Conflict (COMPANY_MEMBER_ALREADY_EXISTS / ORGANIZATION_MEMBER_ALREADY_EXISTS) when the invited email already belongs to a member, matching case-insensitively β previously the invite silently overwrote the member's role
command-handler/commands: the last-owner protection ("Company must have at least one owner") is now a typed command rejection (Account.Privilege.CompanyMustHaveOwner) that survives the command RPC β demoting or removing a company's only owner returns a clear 400 with that message from the API instead of a generic 500
api: member invite endpoints (POST /v1/company/{companyUid}/member, POST /v1/organization/{organizationUid}/member) no longer wait for read-model synchronization β on environments with lagging consumers the sync wait timed out and returned 500 for invites that had already succeeded; the invite is confirmed once the command-handler accepts it
api: observability.lastSuccessfulExportAt on Event Stream exporter responses is no longer documented as required β a freshly created exporter has never delivered anything, so the field may be null or absent
types: incorrectTime, timestamp, and timezoneOffset fields on the DATETIME monitoring log data type (ntpServer becomes optional)
platform-consumer: Compute incorrectTime (timezone mismatch against the platform-configured timezone, or clock deviation over Β±10 minutes) on every device current time report and persist DATETIME monitoring logs into telemetry history, the latest snapshot, and the Redis telemetry cache serving the aggregate latest-telemetry endpoints
api: incorrectTime in the device response currentTime object and in DATETIME telemetry responses (/v1/device/:uid/telemetry/latest, bulk /v1/device/telemetry/latest, and /v1/device/:uid/telemetry/DATETIME/latest)
api: incorrectTime boolean filter on GET /v2/device and GET /v2/device/count (devices that never reported time match neither filter value)
api: POST /v1/device/{deviceUid}/custom-script/latest returns the latest execution for each requested Custom Script in one request
user-domain-model: Add an indexed grouped latest-action-log query
platform-consumer: An error while processing a single cache-invalidation event no longer crashes the whole consumer process β the failed event is dead-lettered for delayed retry individually while the remaining events keep processing; cache invalidation now waits for DB synchronization before resolving the cache key (closing a race with the projection for freshly created devices); a DeviceOrganizationUpdated event for a device missing from the read model (deleted) is treated as a no-op instead of an infinitely retried error; unhandled promise rejections are logged instead of terminating the process; AMQP consumers now start only after service.start() so the process-level error handlers are registered before the first message can arrive
user-domain-model: The per-device bulk operation progress writes (updateSuccessfulDevice, updateFailedDevice, updateSkippedDevice and the increment branch of updateInProgressDevice) now append with $push instead of $addToSet. The stored result is identical thanks to the existing { $ne } filter guards, and the full-array rewrite that capped bulk operations at ~10 devices/s regardless of the configured concurrency is gone.
api: Document Device Custom Script execution pagination and accept canonical result pipeline values without removing legacy values
user-domain-model: The unused deviceTemperature model (createDeviceTemperatureModel, prepareDeviceTemperatureTable, the IDeviceTemperature* schema interfaces and the DeviceCollection.Temperature entry). Nothing ever read from or wrote to the deviceTemperature collection β device temperatures are recorded and served through the device monitoring log (DeviceTelemetryType.TEMPERATURE). The existing collection is left untouched in MongoDB and expires on its own TTL index.
api: The deviceTemperatureModel instance in basicModels, which was constructed but never used. GET /v1/device/:deviceUid/temperature is unaffected β it already reads from the device monitoring log.
platform-consumer: The deviceTemperatureModel instance in mongoModels and its redisModels/models entries, which were constructed but never used.
types: computeDefaultedFeatureFlags helper and ENTITLEMENT_DEFAULTED_DEVICE_FEATURE_FLAGS map in Device/FeatureFlag β defaults unset cpuUsage/memoryUsage device feature flags to enabled when the organization holds the cpuTelemetry/ramTelemetry feature entitlements; explicitly stored values are never overridden
API: The per-device plugin, runner and screenshot write endpoints (POST/PUT/DELETE /v1/device/:deviceUid/plugin, .../runner, and POST /v1/device/:deviceUid/screenshot) now verify the Plugins/Runners/Screenshots entitlement against the organization that owns the target device (via checkPermission's fent), regardless of the caller identity. This closes the gap where a multi-organization account entitled in one organization could act on a device owned by a non-entitled organization. The matching read endpoints keep the caller-organization gate.
API: The organization entitlement cache is kept hot so the caller-organization gate avoids cold read-throughs: it is warmed on startup (after a jittered delay) and re-warmed on a jittered ~4h schedule (all organizations streamed via one fetchList, in the background/non-blocking), and an entitlement change reloads the affected organization's entry instead of only evicting it. It has no TTL β freshness comes from that active invalidation-reload plus the periodic re-warm, not from expiry. Best-effort: the batched read-through remains the fallback, so warmup/reload failures degrade to lazy fetches rather than errors.
user-domain-model: model-readable caches implement CacheWithWarmup β a warmup() that streams the full set (one fetchList) into the cache β and accept ttl: null to never expire; EventsInvalidatedCache gains an opt-in reloadOnInvalidate that re-fetches an invalidated key (keeping it hot) instead of only evicting it.
API: The caller-organization feature-entitlement gate no longer fans out into one entitlement lookup per organization for account tokens β EntitlementProvider.canAny resolves all of the account's organizations in a single batched read, removing an N+1 that raised latency on device endpoints for accounts spanning many organizations.
user-domain-model: Cache.getMany now resolves read-through misses with a single batched $in query (one fetchList) on model-readable caches β for any key, not just uid β instead of one fetchOne per missing key.
api: GET /v1/organization/:organizationUid/device-plan-history endpoint returning an organization's device plan change history (newest record first, human-readable originator), restricted to owner and master roles via the new read_device_plan_history ACL action
command-handler: Record every device plan assignment into the append-only devicePlanHistory on organization create and update ({ plan, originator, date } where plan is a snapshot of the whole assigned device plan as the source of truth for auditing; recorded even when the same plan is set again), starting with the initial plan assigned at organization creation
platform-consumer: Project devicePlanHistory into the organization read model
user-domain-model: Add append-only devicePlanHistory to the organization model
types: Add device plan change history type to Company
api: POST /v1/applet/{appletUid}/version and PUT /v1/applet/{appletUid}/version/{appletVersion} now accept frontAppletVersion: null (bundled front applet) in the JSON body instead of rejecting it with 400 INVALID_BODY_PROPERTIES β the write path already handled null (bundledFrontApplet), only the request schema was too strict
command-handler: Allow creating applicationVersion without frontDisplayVersion
platform-consumer: Allow creating applicationVersion without frontDisplayVersion
api: GET /v1/device/telemetry/latest/count now supports filtering devices by their latest online status using type=ONLINE_STATUS together with the online query parameter
api: PUT /v2/device/{deviceUid}/firmware β device firmware upgrade addressed by the firmware version UID, unambiguous even when the same manufacturer version exists for several firmware types or OS versions; gated by the FirmwareUpgrades entitlement like V1; the V1 version-addressed endpoint is deprecated but stays operational
api: GET /v2/firmware (list, count, by-uid) now returns firmwareType, brand, osVersion, and a computed prettifiedName (webOS manufacturer versions displayed in normalized numerical form, raw values preserved), and supports firmwareType[Prefix|Suffix], brand, and osVersion filters; search now matches firmwareType and brand instead of the deprecated deviceTypes
command-handler: LG-only firmware/device compatibility validation for UID-addressed upgrades β the firmware type and OS version must equal what the device reported (fails closed when unreported) and the normalized manufacturer version must differ from the one already installed; rejected with the Firmware.IncompatibleWithDevice error; non-LG behavior preserved
commands/events: Firmware.UpdateFirmwareVersionMetadata command and Firmware.FirmwareVersionMetadataUpdated event replace the device-types update (Firmware.UpdateFirmwareVersionDeviceTypes removed; the old event stays replayable); FirmwareVersionCreated revision 3 carries scalar firmwareType plus optional brand/osVersion with an upcaster from revision 2
common-types: normalizeNumericalVersion/numericalVersionsEqual helpers for numerical manufacturer/OS version comparison (03.30.16 β‘ 3.30.16)
user-domain-model: firmware version compatibility is now the scalar firmwareType (with optional brand and osVersion, missing on legacy records) instead of the deviceTypes list, which is kept in sync as a deprecated single-item array; firmware version uniqueness moved from (applicationType, version) to the (version, firmwareType, osVersion) tuple, validated authoritatively by command-handler with normalized version semantics (6.1-UL5Q-03.15.60 == 03.15.60 == 3.15.60) β the unique partial index on the raw fields is an exact-format backstop. The event store is the single source of truth for the split: the required command-handler patch 2026-07-16_firmware_version_split_by_firmware_type splits multi-type created events (original UID stays with the first listed type, siblings get deterministic sha256-derived UIDs; records merged by the 2026-06-12 read-model consolidation are recreated under their original event UIDs) and reconciles the platform firmwareVersion collection from the patched events in the same process; the 2026-07-16_firmware_version_firmware_type migration then only backfills scalar fields, resolves duplicates, and creates the unique partial index β it aborts if any unsplit multi-type document remains
command-handler: firmware command rejections now retain their structured error type, preventing expected validation failures from returning HTTP 500
platform-consumer: Device history now records organization tag and location assignment changes
platform-consumer: Device export events (DeviceListExportRequested, DeviceListExportSucceeded, DeviceListExportFailed) are now consumed in batches; export data requests are created and updated with bulk database operations
Exclude Windows devices from auto-banning on serial number change in UpdateDeviceInfo
command-handler: UpdateDeviceInfo now completely ignores a serial number change from an already known value on Windows devices β no DeviceManufacturerDetailsUpdated is emitted for the serial number and the device is not banned (other fields still update normally)
api: POST /v1/applet/{appletUid}/version/{appletVersion}/publish, .../deprecate, and .../renew β set an Applet Version's lifecycle status; guarded so publishing requires a successfully built, not-yet-published version, deprecating requires a non-deprecated version, and renewing requires a deprecated version (409 Conflict on invalid transitions)
api: status field (draft | published | deprecated, derived from publishedSince/deprecatedSince) on the Applet Version resource
api: Content Guard and Policy/Tag operation tags were missing from the global OpenAPI tags declaration, causing documentation tooling to append them out of alphabetical order; the global tags list is now inlined in openapi/schema.yml, sorted alphabetically, and enforced by the operation-tag-defined and tags-alphabetical Redocly lint rules plus a new tags consistency test suite; removed the orphaned deviceOfflineRange.yml spec file that was no longer referenced by any path
api: POST /v1/package/:packageUid/version now allows recreating a soft-deleted package version β creation is only rejected with PACKAGE_VERSION_EXISTS_ERROR when a version with the same buildHash (or metadata version) still exists and has not been deleted
api: Applet version endpoints (/v1/applet/:appletUid/version...), device action log list (/v1/device/:deviceUid/action-log), and device applet command list/create (/v1/device/:deviceUid/applet/:appletUid/command) returned 400 INVALID_PATH_PARAMS for URLs with a trailing slash β trailing slash is now normalized before OpenAPI template matching, restoring pre-migration behavior
api: PUT /v1/organization-tag/:uid request body now accepts parentTagUid: null to detach a tag from its parent (previously a string, or omitted to leave it unchanged)
command-handler: CreateTiming now rejects with Cannot create timing for deprovisioned device when the target device does not belong to an organization (deprovisioning removes it). Closes a race where device-policy-manager re-created a policy timing milliseconds after deprovision (reacting to the deprovision's own TimingDeleted, whose originator has no policyUid), leaving an orphaned timing on a deprovisioned device
api: POST /v1/timing facade now rejects creating a timing for a device that does not belong to an organization (deprovisioned or never provisioned) with 400 DEVICE_NOT_PROVISIONED_TO_TIMING_CREATE, instead of dispatching a CreateTiming that would create an orphaned timing on an unowned device
Device remote-control (kiosk mode / IR remote-control lock, PUT/GET /v1/device/:deviceUid/remote-control) now requires OrganizationEntitlements.DeviceManagement instead of RemoteDesktop. In 44.0.0 it was gated behind the premium Remote Desktop add-on, which returned 402 (INSUFFICIENT_REMOTE_DESKTOP_ENTITLEMENT) for organizations that lock devices into kiosk mode (e.g. via sos kiosk mode) without that add-on. Kiosk locking is core device management and is now consistent with the sibling device-security endpoint.
Device policy status endpoints (list, get by item type) require OrganizationEntitlements.DevicePolicy
API: Feature Entitlement (OrganizationEntitlements.ContentGuard, OrganizationEntitlements.Tags, OrganizationEntitlements.AIContentGuard) enforcement on all Content Guard endpoints β requests from organizations without the required entitlement now receive HTTP 402
Location endpoints (GET/POST/v1/location, GET/PUT/DELETE/v1/location/{locationUid}, add-attachment, remove-attachments, archive, unarchive, restore) now require the Locations feature entitlement on the caller's organization (402 otherwise)
Location organization-tag list endpoints (GET /v1/location/organization-tags, GET /v1/location/{locationUid}/organization-tag) require the Locations entitlement; assign/unassign (PUT/DELETE /v1/location/{locationUid}/organization-tag/{tagUid}) require Locations (caller organization) plus Tags (the tagged location's organization)
Plugin endpoints β GET/POST/PUT/DELETE /v1/plugin/:pluginUid and all Version and Version-Platform sub-resources β now require the OrganizationEntitlements.Plugins organization entitlement and return 402 without it; GET /v1/plugin (list) filters results to entitled organizations (402 only when the caller has no entitled organization)
Runner endpoints β GET/POST/PUT/DELETE /v1/runner/:runnerUid and all Version and Version-Platform sub-resources β now require the OrganizationEntitlements.Runners organization entitlement and return 402 without it; GET /v1/runner (list) filters results to entitled organizations (402 only when the caller has no entitled organization)
Bulk operation endpoints require the BulkActions feature entitlement on the caller's organization (402 otherwise)
Bulk provisioning recipe endpoints require the BulkProvisioning feature entitlement on the caller's organization (402 otherwise)
Device tag endpoints (GET /v1/device/tag, GET/POST/DELETE /v1/device/{deviceUid}/tag[/{tagUid}]) now require the Tags feature entitlement on the caller's organization (402 otherwise)
Device plugin set (PUT /v1/device/{deviceUid}/set-plugin/{pluginUid}) now requires Plugins, and device runner set (PUT /v1/device/{deviceUid}/set-runner/{runnerUid}) requires Runners, on the caller's organization (402 otherwise)
Device location assign/unassign (PUT/DELETE /v1/device/{deviceUid}/location/{locationUid}) now require the Locations feature entitlement on the caller's organization (402 otherwise)
Per-type latest device telemetry (GET /v1/device/{deviceUid}/telemetry/{telemetryType}/latest) now requires the telemetry type's entitlement on the device's organization (402 otherwise)
Custom script version and version-platform endpoints (/v1/custom-script/:customScriptUid/version**, β¦/version/:version/platform**) require the Scripts feature entitlement (402 otherwise)
Package and package version endpoints (/v1/package, /v1/package/:packageUid, /v1/package/:packageUid/version** incl. /file, /icon, /publish, /unpublish) require the PackagesManagement feature entitlement (402 otherwise)
Organization tag endpoints (/v1/organization-tag, /v1/organization-tag/:uid, /v1/organization-tag/tree, /v1/organization-tag/tree/latest) require the Tags feature entitlement (402 otherwise)
events: the event documentation generator now expands constrained generic event payloads to their real shapes instead of an empty {} β e.g. DeviceTelemetryRecordUpdated now documents name as the DeviceTelemetryType enum and data as the full MonitoringLogData payload union (with nested field descriptions preserved)
events: the event documentation generator now preserves field-level JSDoc through the type-fest identity wrappers Simplify/ReadonlyDeep/WritableDeep, so entity create/update events (ICreateEntityEvent/IUpdateEntityEvent) document their fields instead of dropping the descriptions during mapped-type reconstruction
events: documented every field of the public events (device connection, provisioning recipe and telemetry events); the shared type, uid and originator fields are described once on their base types (IEvent, IEntity, IOriginatorAwareEvent)
events: the event documentation generator now surfaces zod .describe() text β it overlays a zod-inferred type's runtime object- and field-level descriptions onto the generated schema, so zod schemas remain the single source of truth for documentation
types: documented the inline telemetry payload fields of MonitoringLogData, so the telemetry data shapes in the generated public event schema now carry field descriptions
events: Device.DeviceConnectionAdded and Device.DeviceConnectionDeleted are now classified as kind: telemetry (not domain) in the generated public event schema, matching their delivery over the telemetry stream
events: DeviceTelemetryRecordUpdated (all versions and its V1βV2βV3 upcasters) now constrains its telemetry-name parameter to DeviceTelemetryType and its payload to MonitoringLogData[N], instead of the previously widened string/unknown; consumers must instantiate it with concrete telemetry types
isMfaRequired field on CreateCompany command and CompanyCreated event (defaults to true), propagated through the platform consumer and persisted on the MongoDB organization model on company creation
hasMfaRequired field accepted in account.settings via the ChangeAccountSettings command and the account API settings object
events: deviceIdentityHash (the public, server-generated device id) added to the exported events Device.Verification.DevicePaired, Device.Verification.DeviceUnpaired, Device.DeviceConnectionAdded, Device.DeviceConnectionDeleted and Device.Telemetry.DeviceTelemetryRecordUpdated (all bumped to _rev: 3). The V2 β V3 upcasters inject deviceIdentityHash: null for historical events.
events: SENSITIVE_FIELDS_BY_EXPORTED_EVENT β a type-safe, exhaustive map (keyed by AllowedEventType) of the sensitive payload fields to strip per exported event, plus the stripSensitiveFields runtime helper. Single source of truth for both event-stream-distributor payload censoring and public-doc generation. Currently strips deviceUid from the six device-identified exported events.
command-handler: handlePairDeviceWithOrganizationAggregate (DevicePaired) and deviceDeprovisionAggregate (DeviceUnpaired) resolve the device and set deviceIdentityHash on emit, throwing if the device is missing.
events: the public event schema generator (generateEventSchemas.ts) now censors the strip-map fields from the public schema only (events-schema-public.json); the internal schema keeps them. Fixed the @public tag placement on DevicePaired/DeviceUnpaired so these exported events are now included in the public schema.
Bump @signageos/amqp to 0.13.x, which replaces the unbounded rejectedTimestamps Map with an LRU+TTL cache, fixing a memory leak in locked event consumption (CU-86c9c16yg)
api: POST /v1/device/verification now accepts an optional deviceName and policy to set on the device during pairing
Firmware versions support multiple device types: deviceTypes array replaces the single deviceType so one firmware record covers all compatible devices per (applicationType, version); new UpdateFirmwareVersionDeviceTypes command + FirmwareVersionDeviceTypesUpdated event allow updating device types of an existing firmware version; migration consolidates duplicated firmware records into one superset record
api: GET /v1/company now accepts a uids query parameter to filter companies by uid (intersected with the caller's accessible companies, so it never widens access)
api: Device telemetry history endpoint GET /v1/device/{deviceUid}/telemetry/{telemetryType} (and its /count companion) now accept since and until query parameters to filter records by createdAt (both inclusive, combinable)
API: Gate Event Stream exporter configuration endpoints (list/count/get/create/update/delete) behind the new EventStreams organization feature entitlement β returns 402 when the organization lacks it
API: Event Stream exporter responses now include per-exporter delivery observability (exported/errors/retries counters, lastSuccessfulExportAt, and the most recent failures on single-config reads)
types/user-domain-model: FROZEN_CONTENT content guard item type
command-handler: Restrict FROZEN_CONTENT content guard items to categories with FROZEN_CONTENT evaluationStrategy (and forbid other item types in such categories)
user-domain-model: Add deprecated field to content guard category model to support deprecating categories that are still referenced by alert rules
api: Add fields deprecated and evaluationStrategy to managed content guard category
Allow CloseDataReporter command to run when the previous run's receivedAt differs by less than 1 second from the 24-hour window boundary (fixes daily data-reporter skip due to millisecond timing jitter)
Platform Consumer no longer crashes on DeviceTelemetryRecordUpdated events with data: null; null telemetry is treated as non-compliant when evaluating policy status