Implement bounded local client app metadata storage#10392
Conversation
Add a small bounded local-node-only metadata store for companion apps,
exposed through the new AdminMessage tags 104..107. Each record carries
an opaque payload up to 512 bytes keyed by app_id, persisted to
/prefs/clientappdata.proto via the existing NodeDB::saveProto helper,
and cleared automatically by factoryReset() through the existing
rmDir("/prefs") path.
Includes:
- protobufs submodule bump to the matching feature commit
- regenerated nanopb output for admin and localonly
- clientAppDataFileName constant in NodeDB.h
- new ClientAppDataStore class (modules/ClientAppDataStore.{h,cpp})
with strict app_id validation (^[a-z0-9._-]{1,32}$), bounded slot
table (max 4 records, no heap), in-place overwrite, slot compaction
on remove, server-set updated_at via getValidTime(RTCQualityDevice)
- ClientAppDataStore::init() wired into main.cpp after nodeDB init,
alongside the other /prefs-reading initializers
The store is namespaced, not owned: firmware enforces shape and
capacity but does not authenticate which client is writing. Callers
must treat payloads as untrusted, optional, and recoverable.
AdminModule dispatch wiring and tests follow in subsequent commits.
Wire AdminMessage dispatch for the four new client_app_data tags
(104..107) into AdminModule::handleReceivedProtobuf:
- set_client_app_data: local-only (mp.from != 0 -> NOT_AUTHORIZED).
On valid input: ClientAppDataStore::set; relies on the existing
auto-ACK path to emit Routing_Error_NONE. Validation, payload-size,
no-space, and storage failures map to Routing_Error_BAD_REQUEST.
- get_client_app_data_request: validates app_id and replies with
get_client_app_data_response. On miss the response carries an
empty app_id sentinel (the firmware returns the _init_default
record because the store leaves *out untouched on NotFound).
Calls setPassKey on the response, mirroring handleGetOwner /
handleGetDeviceUIConfig style. Honors mp.pki_encrypted.
- delete_client_app_data_request: local-only. Treats delete-of-
missing as success (idempotent), matching remove_by_nodenum /
remove_favorite_node / delete_file_request precedent.
Also extend messageIsRequest()/messageIsResponse() to enumerate the
new request/response tags so the auth path does not mis-classify them
as state-changing writes (which would force a session_passkey check).
No new private methods on AdminModule; all logic is inline in the
case bodies, matching the existing handler style.
Add test/test_client_app_data/test_main.cpp covering ClientAppDataStore
end-to-end against a MockNodeDB that intercepts loadProto/saveProto for
the LocalClientAppData descriptor. 27 tests across:
- isValidAppId() accept/reject matrix (conventional names, null,
empty, uppercase, whitespace, oversize, forbidden chars)
- set/get round trip + payload-size bounds (at-max, over-max)
- slot accounting (overwrite reuses, delete frees, max-records enforced)
- delete-then-get NotFound, delete-of-missing semantic
- server-set updated_at (caller-supplied value is ignored, real
epoch when test RTC is seeded by initializeTestEnvironment)
- persistence-survives-reinit (mock retains saved snapshot)
- storage error injection on set and remove
- factory-reset semantic (modeled by mock returning OTHER_FAILURE,
matching the post-rmDir("/prefs") world)
- first-boot empty store
Also mark NodeDB::loadProto and NodeDB::saveProto virtual so tests can
substitute MockNodeDB. Mirrors the existing MockNodeDB::getMeshNode
override pattern in test/test_traffic_management. No production behavior
change; one indirect call per save/load is negligible vs flash I/O.
Dispatch-path tests (AdminModule cases for tags 104..107) are
deliberately omitted as harness-limited: exercising them in isolation
would require standing up channels/service/MeshPacket fakes that the
native test harness does not provide, and would re-test glue logic
the store-level cases already cover. Documented inline at the top
of test_main.cpp.
Both bin/test-native-docker.sh -f test_client_app_data and
bin/test-native-docker.sh -f test_default pass clean. Zero compile
errors, zero linker errors, zero warnings on any feature file.
|
Field-validated this branch end-to-end from an iOS companion app driving the new admin tags over BLE. Posting numbers in case they help on Q3 of the protobufs PR ("acceptable across constrained targets?"). Hardware / firmware. RAK WisMesh Tag (nRF52840) flashed from this PR branch. Companion app: an iOS Flutter client that talks AdminMessage 104..107 directly over the BLE service. App-side payload. A fixed 60-byte structure (2 magic bytes, version, length, flags, seen-bits, hints, timestamps, CRC32). Sits at ~12% of the 512-byte cap; well below worst-case What was exercised.
The persistence-over-reboot leg is the one not explicitly covered in the PR body's Validation section, so flagging it specifically: payload bytes and the firmware-stamped Behaviours worth confirming I'm reading right:
Happy to share the diagnostic-side trace if it would be useful for the maintainer review. |
Summary
Implement the firmware side of the bounded local client-app metadata store proposed in Meshtastic/rfcs#12 and meshtastic/protobufs#907. Adds a tiny new module (
ClientAppDataStore), wires it intoAdminModulefor the four new oneof tags 104..107, persists records to/prefs/clientappdata.proto, and ships native test coverage.This is intentionally namespaced, not owned: the firmware enforces shape and capacity, but does NOT authenticate which companion app is writing. Any admin-capable client may overwrite or delete any
app_id. See the linked RFC for the full namespaced-not-owned framing.Firmware behavior
Three commits on this branch:
firmware: implement local client app data store(a833cfc)firmware: handle client app data admin requests(d054049)firmware: add client app data tests(5979032)Storage path and lifecycle
/prefs/clientappdata.protovia the existingNodeDB::saveProtohelper (atomic viaSafeFile, SPI-locked, power-safe gated).ClientAppDataStore::init(), which is called frommain.cppright afternodeDB = new NodeDBandTransmitHistory::loadFromDisk(), alongside the other/prefs-reading initializers.LOG_WARNfor genuine decode failures).NodeDB::factoryReset()through the existingrmDir("/prefs")path. No new firmware code required.Local-only set/delete, remote authenticated read
In
AdminModule::handleReceivedProtobuf:set_client_app_data(tag 104): rejected withRouting_Error_NOT_AUTHORIZEDifmp.from != 0. Local writes callClientAppDataStore::setand rely on the existing auto-ACK path to emitRouting_Error_NONE.get_client_app_data_request(tag 105): allowed for local clients and for authenticated remote admin (matches every otherget_*_requesttag). Replies withget_client_app_data_response. On miss the response carries an emptyapp_idsentinel.delete_client_app_data_request(tag 107): rejected withNOT_AUTHORIZEDif remote. Treats delete-of-missing as success (idempotent), matchingremove_by_nodenum,remove_favorite_node, anddelete_file_requestprecedent.messageIsRequest()andmessageIsResponse()allowlists are extended to enumerate the new request/response tags so the auth gate does not mis-classify them as state-changing writes.setPassKey()is called on the get response, mirroringhandleGetOwnerandhandleGetDeviceUIConfig. PKI encryption flag is propagated.Error mapping via existing Routing_Error
No new status enum, no new response envelope. All failures map to existing routing-layer errors:
Routing_Error_NONEget_client_app_data_responseget_client_app_data_responsewith emptyapp_idRouting_Error_NONE(idempotent)Routing_Error_BAD_REQUESTRouting_Error_NOT_AUTHORIZEDStore-layer logs (
LOG_WARN/LOG_ERROR) disambiguate the BAD_REQUEST cases for operators.Limits enforced
app_idmatches^[a-z0-9._-]{1,32}$(pure char-by-char check, no<regex>)payloadsize <= 512 bytes (rejected at the store boundary even when nanopb's fixed buffer would otherwise truncate)meshtastic_LocalClientAppData store_;. No heap.app_idreuses the slot (no extra capacity consumed).remove()compacts trailing records to free the slot for re-use.updated_atsemanticsAlways server-set via
getValidTime(RTCQualityDevice). Caller-supplied values are ignored. Returns 0 if the firmware has no valid wall-clock time at write. Matches the spec in the proto comment.NodeDB.h
virtualchangeloadProtoandsaveProtonow havevirtual(added in commit 3, scoped to that commit specifically). Mirrors the existingMockNodeDB::getMeshNode overridepattern intest/test_traffic_management. Cost: one indirect call per save/load (negligible vs flash I/O). Benefit: enables the test suite'sMockNodeDBto substitute these helpers without touching the real filesystem.Portable struct zeroing
Three sites in
ClientAppDataStore.cpp(one ininit(), one inclear(), one inremove()) zero a nanopb-generated POD slot back to its empty state. They usememset(&dst, 0, sizeof(dst))rather than the more idiomaticdst = meshtastic_ClientAppData_init_zero;because the nRF52 toolchain (arm-none-eabi-gcc 9.2.1) rejects brace-init-list assignment to an existing POD struct viaoperator=, while host gcc (used by the Portduino test env) accepts it.memsetis portable across both, and is byte-equivalent for these init-zero macros (every field's zero value is byte-zero).Tests run and results
Both runs: zero compile errors, zero linker errors, zero warnings on any feature file. Validated on the Docker-based
[env:coverage](Portduino-based native, Python 3.14 + PlatformIO 6.1.19).The 27 new tests cover:
isValidAppIdaccept/reject matrix (conventional names, null, empty, uppercase, whitespace, oversize, forbidden chars), set/get round trip with byte-exact verification, payload-size bounds (at-max, over-max), slot accounting (overwrite reuses, max-records enforced, delete frees), delete-then-get NotFound, delete-of-missing semantic, server-setupdated_at(caller value ignored, real epoch when test RTC is seeded), persistence-survives-reinit (mock retains saved snapshot across init reset), storage-error injection on set and remove, factory-reset semantic (modeled by mock returningOTHER_FAILURE, matching the post-rmDir("/prefs")world), and first-boot empty store.Dispatch test harness limitation
Direct dispatch tests for the four new
AdminModulecases are deliberately omitted. The case bodies are thin glue (validation,Result -> Routing_Errormapping, MeshPacket reply construction). Exercising them in isolation would require standing up channels / service / MeshPacket fakes that the native test harness does not provide, and would re-test logic the store-level cases below already cover. The dispatch glue compiles and links via the same coverage build that runs the test suite. Documented inline at the top oftest/test_client_app_data/test_main.cpp.Security caveat: namespaced, not owned
Repeated here for visibility on the firmware PR specifically:
session_passkeyis a node-minted nonce (replay protection, not per-client identity).config.security.admin_key[3]identifies operators who can issue PKC-encrypted remote admin (not per companion app). PhoneAPI sees a single undifferentiated stream from the connected phone. There is no firmware primitive today that could be repurposed for per-app ownership; adding one would be a separate, larger RFC.Compatibility
Wire-additive only. Existing clients are unaffected. Companion apps built against this PR will get either
Routing_Error_NOT_AUTHORIZED(today's catch-all for unknown admin payloads on older builds) or no reply at all when talking to firmware that doesn't implement the feature; both must be treated as "feature unavailable, fall back to app-local storage."No version bump. No
device_statemigration. The on-disk file is a fresh/prefs/clientappdata.proto; absence is the expected first-boot state.Hardware E2E validation plan
Native tests pass on the Docker
coverageenv (Portduino-based). The nRF52 build of this branch (arm-none-eabi-gcc 9.2.1) was also validated locally for the WisMesh Tag environment (bash bin/build-nrf52.sh rak_wismeshtag, SUCCESS, UF2 + DFU OTA zip generated). Real-device validation has been executed against a flashed RAK WisMesh Tag and passed all checklist steps, with results captured in the "Hardware E2E validation result" subsection below.Target device
RAK WisMesh Tag (an nRF52840 RAK4631-family tag from RAKwireless / MOKO Smart that the developer has on hand). Single representative target; if maintainers want broader coverage before merge, happy to extend to a second target.
Why this is not part of the current native/unit phase
The native
coverageenv exercises every line of the new module and dispatch glue under a real (mocked-NodeDB) lifecycle, but it does not execute the embedded toolchain, real flash I/O, the actualSafeFileatomic-write code path, or any BLE/serial transport. Hardware E2E catches: real-flash write/read round-trips, BLE GATT transport sizing for the 512-byte payload, real RTC behavior forupdated_at(vs the host-clock seed in the test harness), and any platform-specific#ifdefpaths that the Portduino build skips.Splitting it off keeps the merge-blocking phase (CI-friendly native tests) cheap and runnable by maintainers without dedicated hardware, and lets the real-device pass land as a comment on the PR with logs and an artifact reference.
Validation checklist
bash bin/build-nrf52.sh rak_wismeshtag. Produces UF2 + DFU OTA zip + ELF + manifest underrelease/.set_client_app_datawith the test record:app_id = "app.mesh.test"version = 1payload= a deterministic 64-byte pattern (e.g.0..63)updated_atzero on the wire (firmware overwrites it)get_client_app_data_request. Verify byte-for-byte equality onpayload,version == 1,app_id == "app.mesh.test",updated_atis a real epoch (or 0 if RTC is uninitialized on this build).delete_client_app_data_request("app.mesh.test"). Repeat the read; verify the response is aget_client_app_data_responsewith emptyapp_id(the NOT_FOUND sentinel).factory_reset_configis enough;factory_reset_devicealso fine). Re-bond. Repeat the read; verify the record is still absent (factory reset cleared/prefsincluding our file).Use the test
app_idapp.mesh.testthroughout. Do not use any production app_id during validation.Rollback / safety checklist
backup_preferencesto flash, or pull config via the Meshtastic CLI to a local file.meshtastic.org/downloads/or the latest GitHub Release), and verify the official Meshtastic flasher can re-flash it from a clean device. If anything goes sideways, this is the recovery path.Where this happens
After the RFC PR and Protobufs PR are open and linked. Either before or during firmware PR review, depending on maintainer cadence. The validation note will be appended to this PR as a comment with an artifact link and a checklist of pass/fail per step.
Hardware E2E validation result
5aad5ed6firmware-rak_wismeshtag-2.7.23.5979032.uf2960e8c28fa1db02f6b0bf035a272e932app.mesh.testPer-step results:
app_idsentinel returned, NOT_FOUND)app_id="app.mesh.test",version=1,payload=64B, byte-for-byte match against the deterministic pattern)app_id,version, 64-byte payload, and the firmware-setupdated_at)app_idsentinel)app_idsentinel)Notes:
updated_atvalue remained byte-equal across the manual reboot, and the 64-byte payload verified byte-for-byte after reconnect. This confirmsNodeDB::saveProto("/prefs/clientappdata.proto", ...)survived the power cycle and was re-loaded byClientAppDataStore::init()on boot.app_idsentinel confirmed after delete and after a clean factory state, exactly as documented for tag 106.Linked