Version: 4.11.3 (multi-source architecture)
Stack: React 19 + TS + Vite frontend / Node.js 20+ (Docker image ships Node 24; CI matrix covers 20/22/24/25) + Express 5 + TS backend / SQLite (default), PostgreSQL, MySQL via Drizzle ORM / Meshtastic protobuf-over-TCP and MeshCore (native meshcore.js for companion, serial CLI for repeater) through a per-source manager registry.
- This file — invariants, rules, and gotchas. Skim end-to-end.
docs/internal/dev-notes/ARCHITECTURE_LESSONS.md— MUST-READ before touching node communication, state management, backup/restore, async operations, multi-database, or multi-source.docs/internal/dev-notes/MESHCORE_REMOTE_ADMIN.md— MUST-READ before touching MeshCore CLI/admin routes, the credential store, the danger guard, or the sharedCliConsoleBodyprimitive.src/server/sourceManagerRegistry.ts+src/server/meshtasticManager.ts— read these two before any feature touching nodes/messages/telemetry. There is no globalmeshtasticManagersingleton; everything is per-source.- One repository under
src/db/repositories/(e.g.auth.ts) — read before adding a query. Raw SQL outside this directory is ESLint-banned.
| Subsystem | Primary files |
|---|---|
| Multi-source registry | src/server/sourceManagerRegistry.ts, src/server/meshtasticManager.ts, src/server/meshcoreManager.ts (parallel Meshcore protocol; not a fallback), src/contexts/SourceContext.tsx |
| Auth + permissions | src/server/auth/, src/db/repositories/auth.ts, src/db/repositories/permissions.ts |
| Database backends | src/db/drivers/{sqlite,postgres,mysql}.ts, src/db/schema/, src/db/repositories/ |
| Migrations | src/server/migrations/NNN_*.ts (75+ total), registry in src/db/migrations.ts |
| Backup/restore | src/server/services/systemBackupService.ts, systemRestoreService.ts |
| Routes | src/server/routes/* |
| Packet monitors | Meshtastic: packet_log table + packetLogService.ts + packetRoutes.ts + PacketMonitorPanel.tsx. MeshCore (OTA via LogRxData): meshcore_packet_log table + meshcorePacketLogService.ts + /packets routes in meshcoreRoutes.ts + MeshCorePacketMonitorView.tsx. Both opt-in (*_packet_log_enabled). |
| Frontend pages | src/pages/* (Unified*Page = multi-source aware) |
| ESLint config | eslint.config.mjs (raw-SQL ban lives here) |
- Backend talks to nodes; frontend never does. All node IO goes through
sourceManagerRegistry.getManager(sourceId). - Per-source scoping is mandatory. Every query against nodes/messages/telemetry/traceroutes/etc. must take a
sourceId. Searchsrc/db/schema/forsourceIdto enumerate. - No raw SQL outside
src/db/repositories/andsrc/server/migrations/. ESLint-enforced viano-restricted-syntaxineslint.config.mjs. - All DatabaseService methods are async (
Asyncsuffix). Tests mock withmockResolvedValue. - Never push directly to main. Always use a branch.
- After bulk find-and-replace or sed, verify modified functions have correct
async/awaitsignatures. Route handlers and callbacks needasyncifawaitwas added inside.
MeshMonitor 4.x supports N concurrent Meshtastic node connections ("sources"). Pre-4.0 code that referenced a singleton meshtasticManager is now a @deprecated JSDoc-tagged compatibility shim at the bottom of src/server/meshtasticManager.ts — IDEs will strikethrough usages but tsc does not error.
- No global
meshtasticManagersingleton. Look up per-source instances viasourceManagerRegistry.getManager(sourceId). - Every packet/node/message/telemetry/traceroute/neighbor/channel/embed-profile/ignored-node/distance-delete/time-sync/meshcore row carries a
sourceId. Migrations 020–062 are mostly source-scoping work. Repository queries that don't scope bysourceIdwill leak data across sources. Exceptions (global-by-design): (1) thechannel_database(server-side decryption PSKs) —channelDecryptionServicetries every enabled row regardless of source, and migration 063 dropped its deadsourceIdcolumn. (2) theestimated_positionstable (issue #3271) — one row per physicalnodeNum, pooled from traceroute + neighbor observations across ALL Meshtastic sources (incl. MQTT) by the scheduledpositionEstimationService; estimation is Meshtastic-only (MeshCore excluded) and runs as a global batch job (positionEstimationScheduler), not in realtime. (3) theautomations/automation_runs/automation_variables/automation_variable_valuestables (issue #3653, Automation Engine) — automations and their user-defined variables are global (nosourceId); a workflow evaluates against events from every source, and acondition.sourceFilterblock inside itsconfiggraph scopes it to a subset. Variable values are keyed by an explicitscopeKey(global/source/node/sourceNode) rather than a row-levelsourceId. Seedocs/internal/dev-notes/AUTOMATION_ENGINE_PLAN.md. Adding a new per-source data type should still get asourceIdcolumn unless you have a concrete cross-source-by-design reason like decryption. - Permissions are per-source.
permissions.sourceIdwas added in migration 022, refined in 033.requirePermission(resource, action)middleware honors source scoping. - Frontend uses
SourceContext(src/contexts/SourceContext.tsx).useSource()returns{ sourceId, sourceName };sourceIdisnulloutside aSourceProvider(legacy/single-source views). Unified*Pagecomponents are cross-source consumers (UnifiedMessagesPage,UnifiedTelemetryPage,DashboardPage).
- Decide: global (one row regardless of source) or per-source.
- If per-source: add
sourceIdto all three schemas insrc/db/schema/<table>.ts. - Write a migration that backfills existing rows with the default source (see migration 050
promote_globals_to_default_source). - Scope every query by
sourceIdviawithSourceScope(table, sourceId)(insrc/db/repositories/base.ts). - Add a
*.perSource.test.tsasserting source isolation.
Three backends. Default is SQLite. PG triggered by DATABASE_URL (postgres://), MySQL by DATABASE_URL (mysql://). Check databaseService.drizzleDbType for runtime backend ('sqlite' | 'postgres' | 'mysql').
- Use Drizzle ORM — never write raw SQL that isn't database-agnostic.
- Test with SQLite first — it's the default and most common deployment.
- Node IDs / packet IDs are BIGINT in PostgreSQL/MySQL. Always coerce to
Numberwhen comparing (Number(row.nodeNum)). PG/MySQLINTEGERis signed 32-bit; nodeNum is unsigned 32-bit. - Boolean columns differ: SQLite uses 0/1, PostgreSQL uses true/false. Drizzle handles this.
- Schema definitions live in
src/db/schema/— one file per domain, three table definitions per backend. - Column naming: SQLite uses
snake_case, PostgreSQL/MySQL usecamelCase(quoted in PG raw SQL).
src/services/database.ts # Main service - facade over repositories
src/db/
schema/ # Drizzle schema (database-agnostic)
repositories/ # Domain-specific async repositories
drivers/{sqlite,postgres,mysql}.ts
- Add async method to the appropriate repository in
src/db/repositories/. - Expose through DatabaseService with
Asyncsuffix. - Use Drizzle query builders — they generate correct SQL for each backend.
- For unavoidable raw SQL inside a repo, branch on
db.drizzleDbTypefor dialect-correct syntax. - IMPORTANT: When adding routes that use database methods, ensure tests mock the async versions.
Mock async methods used by authMiddleware:
(DatabaseService as any).findUserByIdAsync = vi.fn().mockResolvedValue(user);
(DatabaseService as any).findUserByUsernameAsync = vi.fn().mockResolvedValue(null);
(DatabaseService as any).checkPermissionAsync = vi.fn().mockResolvedValue(true);
(DatabaseService as any).getUserPermissionSetAsync = vi.fn().mockResolvedValue({ resources: {}, isAdmin: false });For per-source permission tests, mock getUserPermissionSetAsync(userId, sourceId) to return source-specific resource maps.
For route tests that exercise requirePermission / optionalAuth, use createRouteTestApp() (src/server/test-helpers/routeTestApp.ts) instead of monkey-patching checkPermissionAsync or mocking the whole services/database.js module. The harness wires real express-session (MemoryStore) + real auth middleware against the singleton's :memory: SQLite DB, and seeds per-test permission rows so that real SQL enforces isolation.
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestApp({ mount: app => app.use('/', myRouter) });
await harness.grant(harness.limited.id, 'nodes', 'read', harness.sourceA);
});
afterEach(() => harness.cleanup());- New or changed route tests MUST use the harness. Legacy tests using
vi.mock('../../services/database.js', ...)convert opportunistically when the file is touched. - The monkey-patch pattern (
vi.mock(...)+ fakecheckPermissionAsynclambda) is deprecated for route tests. A fake that re-implements the logic under test cannot catch regressions in that logic. - See
src/server/routes/sourceRoutes.permissions.test.tsas the canonical template. - Non-DB mocks (
sourceManagerRegistry,meshtasticManager, service mocks) are still correct and must remain.
src/services/database.tsis raw-SQL-free. All domain queries live insrc/db/repositories/*.- ESLint rule (
no-restricted-syntaxineslint.config.mjs) forbidsthis.db.prepare,this.db.exec,postgresPool.query,mysqlPool.queryoutsidesrc/server/migrations/**and test files. - Intentional exceptions must carry
// eslint-disable-next-line no-restricted-syntax -- <reason>.
Migrations use a centralized registry in src/db/migrations.ts. Each migration has functions for all three backends.
Current migration count: 112 (latest: 112_add_notes_to_nodes).
For the full "adding a migration" recipe see Migration recipe below.
- context7 MCP for library/framework/API docs — use without being asked.
- serena MCP symbolic tools (
find_symbol,get_symbols_overview,find_referencing_symbols,search_for_pattern) — preferred over grep for code navigation. - superpowers skills for planning/workflow — use without being asked. Key ones:
brainstorming,writing-plans,executing-plans,test-driven-development,verification-before-completion,systematic-debugging. - Slash commands in
.claude/commands/:/ci-monitor,/create-pr,/create-release,/release-monitor,/worktree,/worktree-cleanup.
- Default admin account: username
admin, passwordchangeme(seeded byDatabaseServiceon first boot when no admin exists; logged to stdout). The login UI surfaces anisDefaultPassword=truewarning until this is changed. - Default SQLite path:
/data/meshmonitor.db(set viaDATABASE_PATHenv var). This default is the same on every platform — baremetal Node deployments outside Docker must either create/data/writable to the runtime user OR setDATABASE_PATHto a different location. - Load the app at
http://localhost:8080for dev-container testing. The webserver hasBASE_URLconfigured for/meshmonitor. - Don't run the Docker dev container and a local
npm run devat the same time — they fight over ports. - The dev container does NOT have
sqlite3available as a CLI binary. - When sending test messages, use the
gauntletchannel — never the Primary channel. - Tileserver runs on port 8082. Only shut it down (and the dev container) when you are running
tests/system-tests.shlocally to debug a system-test failure — CI runs system tests on every PR, so you should not be invoking that script as part of normal feature/bugfix work.
- System tests (
tests/system-tests.sh) are run by CI on every PR. Do not run them locally as part of normal feature or bugfix work — only run them locally when you are specifically debugging a system-test failure. - After creating or updating a PR, use the
/ci-monitorskill to monitor CI status and auto-fix any failures (system-test regressions show up there). - All tests must pass (0 failures) before creating a PR. Run the full Vitest suite, not just targeted tests, before committing migration or refactor work.
- When migrating test mocks from sync to async, use
mockResolvedValue(notmockReturnValue) for any function that returns a Promise. - When testing locally, use
docker-compose.dev.ymlto build the local code, and verify the proper code was deployed once the container launches.
When sending NodeInfo exchanges for key repair (auto-key management, immediate purge, manual button), always send on the node's channel, not as a DM. PKI-encrypted DMs use the stored key, which is wrong when there's a key mismatch. Channel routing uses the shared PSK which works regardless.
The remainder of this file is reference detail used less often than the rules above.
- Create
src/server/migrations/NNN_description.tswith:export const migration = { up: (db: Database) => {...} }for SQLiteexport async function runMigrationNNNPostgres(client)for PostgreSQLexport async function runMigrationNNNMysql(pool)for MySQL
- Register it in
src/db/migrations.tswithregistry.register({ number, name, settingsKey, sqlite, postgres, mysql }). - Update
src/db/migrations.test.ts(count, last migration name). - Make migrations idempotent — try/catch for SQLite (
duplicate column),IF NOT EXISTSfor PostgreSQL,information_schemachecks for MySQL.
Use scripts/api-test.sh for authenticated API testing against the running dev container:
./scripts/api-test.sh login # Login and store session
./scripts/api-test.sh get /api/endpoint # Authenticated GET request
./scripts/api-test.sh post /api/endpoint '{"data":"value"}' # POST request
./scripts/api-test.sh delete /api/endpoint # DELETE request
./scripts/api-test.sh logout # Clear stored sessionDefault credentials: admin/changeme1. Override with API_USER and API_PASS env vars.
When adding a new user-configurable setting:
- MUST add the key to
src/server/constants/settings.tsVALID_SETTINGS_KEYS— without this, the setting silently fails to save. - In
SettingsTab.tsx, thehandleSaveuseCallbackhas a large dependency array — newlocalFoostate AND the contextsetFoosetter must be added to it, or the save callback uses stale values. - See
src/contexts/SettingsContext.tsxfor the full state/setter/localStorage/server-load pattern.
- When updating the version, update all five files:
package.json,package-lock.json(regenerate vianpm install --package-lock-only—.npmrcnow pinslegacy-peer-deps=true, so the explicit flag is no longer needed),helm/meshmonitor/Chart.yaml,desktop/src-tauri/tauri.conf.json,desktop/package.json. - Use shared constants from
src/server/constants/meshtastic.tsfor PortNum, RoutingError, and helper functions — never magic numbers for protocol values. - Official Meshtastic protobuf definitions: https://github.com/meshtastic/protobufs/
The Proxmox LXC template is built by lxc/build-lxc-template.sh using a
partial + sparse git clone. The cone directory list lives in
lxc/sparse-cone.txt — not in the build script itself.
- If you add a top-level directory that is required at runtime, add it to
lxc/sparse-cone.txtin the same commit. Omitting it silently drops those files from every future LXC template build. - All
npm install/npm run buildsteps run insidechrootagainst the container's own Node.js (NodeSource 24). Never move them to the host/CI workspace — native modules (better-sqlite3) must compile for the container's ABI, not the runner's. PUPPETEER_SKIP_DOWNLOAD=trueon every npm step. The container is headless — Chromium download will fail or hang without it..gitinside/opt/meshmonitoris intentional. It is what enablesmeshmonitor-updateto manage future in-place upgrades. Do not add it to.gitignoreor strip it in cleanup steps.