Add database-backed channels with role-based visibility and web dashboard

Replaces env-var channel keys with a Channel database model and periodic
DB refresh in the collector. Adds Channels dashboard page with QR codes,
channel visibility filtering on messages/dashboard APIs, and channel card
navigation to filtered messages view.
This commit is contained in:
Louis King
2026-05-20 00:37:05 +01:00
parent d2579dd51d
commit 5f6d44c7b8
41 changed files with 1990 additions and 134 deletions
@@ -74,8 +74,6 @@ Some code bypasses Pydantic Settings entirely and reads env vars directly:
| File | Env Var | Default | Purpose |
|------|---------|---------|---------|
| `src/meshcore_hub/web/app.py` | `COLLECTOR_CHANNEL_KEYS` | `None` | Reads channel keys for web UI label building |
| `src/meshcore_hub/web/app.py` | `COLLECTOR_INCLUDE_TEST_CHANNEL` | `"false"` | Reads test channel flag for web UI |
| `src/meshcore_hub/common/health.py` | `HEALTH_DIR` | `/tmp/meshcore-hub` | Health status file directory |
| `src/meshcore_hub/alembic/env.py` | `DATABASE_URL` | Falls to config | Alembic migration DB URL |
| `src/meshcore_hub/alembic/env.py` | `DATA_HOME` | Falls to config | Alembic fallback for computing DB URL |
@@ -141,8 +141,7 @@ Complete list from `docker-compose.yml` collector `environment:` block:
| `MQTT_WS_PATH` | `/` | MQTT |
| `DATA_HOME` | `/data` (hardcoded) | Path |
| `SEED_HOME` | `/seed` (hardcoded) | Path |
| `COLLECTOR_CHANNEL_KEYS` | (empty) | Collector |
| `COLLECTOR_INCLUDE_TEST_CHANNEL` | `false` | Collector |
| `CHANNEL_REFRESH_INTERVAL_SECONDS` | `300` | Collector |
| `WEBHOOK_ADVERTISEMENT_URL` | (passthrough) | Webhook |
| `WEBHOOK_ADVERTISEMENT_SECRET` | (passthrough) | Webhook |
| `WEBHOOK_MESSAGE_URL` | (passthrough) | Webhook |
@@ -221,8 +220,7 @@ Complete list from `docker-compose.yml` collector `environment:` block:
| `NETWORK_WELCOME_TEXT` | (empty) | Network |
| `CONTENT_HOME` | `/content` (hardcoded) | Path |
| `TZ` | `UTC` | Display |
| `COLLECTOR_CHANNEL_KEYS` | (empty) | Display |
| `COLLECTOR_INCLUDE_TEST_CHANNEL` | `false` | Display |
| `FEATURE_CHANNELS` | `true` | Feature |
| `FEATURE_DASHBOARD` | `true` | Feature |
| `FEATURE_NODES` | `true` | Feature |
| `FEATURE_ADVERTISEMENTS` | `true` | Feature |
@@ -230,6 +228,7 @@ Complete list from `docker-compose.yml` collector `environment:` block:
| `FEATURE_MAP` | `true` | Feature |
| `FEATURE_MEMBERS` | `true` | Feature |
| `FEATURE_PAGES` | `true` | Feature |
| `FEATURE_CHANNELS` | `true` | Feature |
### Observer (Packet Capture) Env Vars
@@ -144,7 +144,7 @@ docs/letsmesh.md documents the LetsMesh packet normalization and decoding behavi
- [ ] MQTT subscription topics match `subscriber.py` topic patterns
- [ ] Payload type mappings match `letsmesh_decoder.py` and `letsmesh_normalizer.py` logic
- [ ] Channel key handling documented matches `COLLECTOR_CHANNEL_KEYS` config behavior
- [ ] Channel key handling documented matches database-backed channels (channels table, CHANNEL_REFRESH_INTERVAL_SECONDS)
- [ ] Known channel indexes (`17 -> Public`, `217 -> #test`) match built-in defaults in decoder
- [ ] Message normalization rules match collector handler implementations
- [ ] GPS/location update behavior documented matches advertisement handler logic
@@ -219,7 +219,7 @@ Verify sections exist and are correctly ordered:
2. [ ] Common Settings (`COMPOSE_PROJECT_NAME`, `TRAEFIK_DOMAIN`, `IMAGE_VERSION`, `LOG_LEVEL`, `DATA_HOME`, `SEED_HOME`)
3. [ ] MQTT Settings (`MQTT_HOST`, `MQTT_PORT`, `MQTT_USERNAME`, `MQTT_PASSWORD`, `MQTT_PREFIX`, `MQTT_TLS`, `MQTT_TRANSPORT`, `MQTT_WS_PATH`, `MQTT_TOKEN_AUDIENCE`)
4. [ ] Packet Capture Settings (all `PACKETCAPTURE_*` vars + `SERIAL_PORT`)
5. [ ] Collector Settings (`COLLECTOR_CHANNEL_KEYS`, `COLLECTOR_INCLUDE_TEST_CHANNEL`, webhooks, retention, cleanup)
5. [ ] Collector Settings (`CHANNEL_REFRESH_INTERVAL_SECONDS`, webhooks, retention, cleanup)
6. [ ] API Settings (`API_PORT`, `API_READ_KEY`, `API_ADMIN_KEY`, metrics)
7. [ ] Web Dashboard Settings (`WEB_PORT`, `API_BASE_URL`, `API_KEY`, theme, locale, auto-refresh, admin, TZ, content home, network info, feature flags, contact info)
@@ -243,7 +243,7 @@ For every variable in `.env.example`:
- [ ] `MQTT_WS_PATH` comment notes default `/` vs production `/mqtt`
- [ ] `MQTT_TOKEN_AUDIENCE` comment explains it must match broker config
- [ ] `PACKETCAPTURE_*` comments reference the external packet capture image
- [ ] `COLLECTOR_CHANNEL_KEYS` comment explains label=hex format
- [ ] `CHANNEL_REFRESH_INTERVAL_SECONDS` comment explains channel key refresh from database
- [ ] `WEB_*` comments reference web dashboard behavior
- [ ] `FEATURE_*` comments explain what each flag controls
- [ ] `NETWORK_*` comments explain where values appear in UI
+10 -9
View File
@@ -197,15 +197,15 @@ PACKETCAPTURE_EXIT_ON_RECONNECT_FAIL=true
# LetsMesh decoder support
# The native Python decoder is always enabled.
# Optional: channel secret keys (comma or space separated) used to decrypt GroupText
# packets. This supports unlimited keys.
# Note: Public + #test keys are built into the collector code by default.
# To show friendly channel names in the web feed, use label=hex (example: bot=ABCDEF...).
# Without keys, encrypted packets cannot be shown as plaintext.
# COLLECTOR_CHANNEL_KEYS=
# Include built-in 'test' channel messages (channel_idx 217)
# Default: false (test channel messages are discarded)
# COLLECTOR_INCLUDE_TEST_CHANNEL=false
# -------------------
# Channel Settings
# -------------------
# Channel keys are now managed via the database (channels table).
# Use `meshcore-hub collector channel add --name X --key HEX` or seed via channels.yaml.
# See docs/seeding.md for the channels.yaml format.
#
# Refresh interval for reloading channel keys from the database (seconds).
# CHANNEL_REFRESH_INTERVAL_SECONDS=300
# -------------------
# Webhook Settings
@@ -457,6 +457,7 @@ NETWORK_ANNOUNCEMENT=
# FEATURE_MAP=true
# FEATURE_MEMBERS=true
# FEATURE_PAGES=true
# FEATURE_CHANNELS=true
# -------------------
# Contact Information
+7 -5
View File
@@ -264,11 +264,13 @@ meshcore-hub/
│ │ ├── hash_utils.py # Hash utility functions
│ │ ├── models/ # SQLAlchemy models
│ │ │ ├── node.py # Node model
│ │ │ ├── channel.py # Channel model (encryption keys)
│ │ │ ├── user_profile.py # User profile model (OIDC users)
│ │ │ ├── user_profile_node.py # User-node adoption join table
│ │ │ └── ...
│ │ └── schemas/ # Pydantic schemas
│ │ ├── user_profiles.py # User profile API schemas
│ │ ├── channels.py # Channel API schemas
│ │ └── ...
│ ├── collector/
│ │ ├── cli.py # Collector CLI with seed commands
@@ -287,8 +289,9 @@ meshcore-hub/
│ │ ├── metrics.py # Prometheus metrics endpoint
│ │ └── routes/ # API routes
│ │ ├── user_profiles.py # User profile endpoints (GET/PUT profile)
│ │ ├── adoptions.py # Node adoption endpoints (POST adopt, DELETE release)
│ │ └── ...
│ │ ├── adoptions.py # Node adoption endpoints (POST adopt, DELETE release)
│ │ ├── channels.py # Channel CRUD endpoints (GET/POST/PUT/DELETE channels)
│ │ └── ...
│ └── web/
│ ├── cli.py
│ ├── app.py # FastAPI app
@@ -635,8 +638,7 @@ Key variables:
- `MQTT_TRANSPORT` - MQTT transport protocol (default: `websockets`)
- `MQTT_WS_PATH` - WebSocket path (default: `/`)
- `MQTT_TLS` - Enable TLS/SSL for MQTT (default: `false`, set `true` for `wss://`)
- `COLLECTOR_CHANNEL_KEYS` - Additional decoder channel keys for decrypting GroupText packets
- `COLLECTOR_INCLUDE_TEST_CHANNEL` - Include built-in 'test' channel messages (default: `false`)
- `CHANNEL_REFRESH_INTERVAL_SECONDS` - Seconds between channel key refresh from database (default: `300`, min: `10`)
- `API_HOST` - API server bind address (default: `0.0.0.0`)
- `API_PORT` - API server port (default: `8000`)
- `API_READ_KEY`, `API_ADMIN_KEY` - API authentication keys
@@ -668,7 +670,7 @@ Key variables:
- `WEB_AUTO_REFRESH_SECONDS` - Auto-refresh interval in seconds for list pages (default: `30`, `0` to disable)
- `WEB_DEBUG` - Enable debug mode in the web dashboard (default: `false`)
- `TZ` - Timezone for web dashboard date/time display (default: `UTC`, e.g., `America/New_York`, `Europe/London`)
- `FEATURE_DASHBOARD`, `FEATURE_NODES`, `FEATURE_ADVERTISEMENTS`, `FEATURE_MESSAGES`, `FEATURE_MAP`, `FEATURE_MEMBERS`, `FEATURE_PAGES` - Feature flags to enable/disable specific web dashboard pages (default: all `true`). Dependencies: Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
- `FEATURE_DASHBOARD`, `FEATURE_NODES`, `FEATURE_ADVERTISEMENTS`, `FEATURE_MESSAGES`, `FEATURE_MAP`, `FEATURE_MEMBERS`, `FEATURE_PAGES`, `FEATURE_CHANNELS` - Feature flags to enable/disable specific web dashboard pages (default: all `true`). Dependencies: Dashboard auto-disables when all of Nodes/Advertisements/Messages are disabled. Map auto-disables when Nodes is disabled.
- `NETWORK_DOMAIN` - Network domain name (default: none)
- `NETWORK_NAME` - Network display name (default: `MeshCore Network`)
- `NETWORK_CITY` - Network city location (default: none)
+3 -4
View File
@@ -330,10 +330,9 @@ All components are configured via environment variables. Create a `.env` file or
### Collector Settings
| Variable | Default | Description |
| -------------------------------- | -------- | -------------------------------------------------------------------- |
| `COLLECTOR_CHANNEL_KEYS` | _(none)_ | Additional decoder channel keys (`label=hex`, `label:hex`, or `hex`) |
| `COLLECTOR_INCLUDE_TEST_CHANNEL` | `false` | Include built-in 'test' channel messages |
| Variable | Default | Description |
| ---------------------------------- | ------- | -------------------------------------------------------- |
| `CHANNEL_REFRESH_INTERVAL_SECONDS` | `300` | Seconds between channel key refresh from database (min 10) |
#### LetsMesh Packet Decoding
+1 -1
View File
@@ -186,7 +186,7 @@ Group/broadcast messages on specific channels.
- In LetsMesh upload compatibility mode, packet type `5` is normalized to `CHANNEL_MSG_RECV` and packet types `1`, `2`, and `7` are normalized to `CONTACT_MSG_RECV` when decryptable text is available.
- LetsMesh packets without decryptable message text are treated as informational `letsmesh_packet` events instead of message events.
- For UI labels, known channel indexes are mapped (`17 -> Public`, `217 -> #test`) and preferred over ambiguous/stale channel-name hints.
- Additional channel labels can be provided through `COLLECTOR_CHANNEL_KEYS` using `label=hex` entries.
- Additional channel labels are loaded from the `channels` database table via the collector's periodic refresh.
- When decoder output includes a human sender (`payload.decoded.decrypted.sender`), message text is normalized to `Name: Message`; sender identity remains unknown when only hash/prefix metadata is available.
**Compatibility ingest note (advertisements)**:
@@ -0,0 +1,58 @@
"""add channels table
Revision ID: 82dff87d6576
Revises: 20260515_1920
Create Date: 2026-05-19 21:25:58.828179+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "82dff87d6576"
down_revision: Union[str, None] = "20260515_1920"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"channels",
sa.Column("name", sa.String(length=100), nullable=False),
sa.Column("key_hex", sa.String(length=64), nullable=False),
sa.Column("channel_hash", sa.String(length=2), nullable=False),
sa.Column("visibility", sa.String(length=20), nullable=False),
sa.Column("enabled", sa.Boolean(), nullable=False),
sa.Column("id", sa.String(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("(CURRENT_TIMESTAMP)"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("key_hex"),
)
with op.batch_alter_table("channels", schema=None) as batch_op:
batch_op.create_index(batch_op.f("ix_channels_name"), ["name"], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("channels", schema=None) as batch_op:
batch_op.drop_index(batch_op.f("ix_channels_name"))
op.drop_table("channels")
# ### end Alembic commands ###
+2 -4
View File
@@ -155,8 +155,7 @@ services:
- MQTT_TLS=${MQTT_TLS:-false}
- MQTT_TRANSPORT=${MQTT_TRANSPORT:-websockets}
- MQTT_WS_PATH=${MQTT_WS_PATH:-/}
- COLLECTOR_CHANNEL_KEYS=${COLLECTOR_CHANNEL_KEYS:-}
- COLLECTOR_INCLUDE_TEST_CHANNEL=${COLLECTOR_INCLUDE_TEST_CHANNEL:-false}
- CHANNEL_REFRESH_INTERVAL_SECONDS=${CHANNEL_REFRESH_INTERVAL_SECONDS:-300}
- DATA_HOME=/data
- SEED_HOME=/seed
# Webhook configuration
@@ -295,8 +294,6 @@ services:
- NETWORK_ANNOUNCEMENT=${NETWORK_ANNOUNCEMENT:-}
- CONTENT_HOME=/content
- TZ=${TZ:-UTC}
- COLLECTOR_CHANNEL_KEYS=${COLLECTOR_CHANNEL_KEYS:-}
- COLLECTOR_INCLUDE_TEST_CHANNEL=${COLLECTOR_INCLUDE_TEST_CHANNEL:-false}
# Feature flags (set to false to disable specific pages)
- FEATURE_DASHBOARD=${FEATURE_DASHBOARD:-true}
- FEATURE_NODES=${FEATURE_NODES:-true}
@@ -305,6 +302,7 @@ services:
- FEATURE_MAP=${FEATURE_MAP:-true}
- FEATURE_MEMBERS=${FEATURE_MEMBERS:-true}
- FEATURE_PAGES=${FEATURE_PAGES:-true}
- FEATURE_CHANNELS=${FEATURE_CHANNELS:-true}
command: ["web"]
healthcheck:
test:
+2 -2
View File
@@ -21,8 +21,8 @@ The collector subscribes to packets published by [meshcore-packet-capture](https
- For channel packets, if a channel key is available, a channel label is attached (for example `Public` or `#test`) for UI display.
- In the messages feed and dashboard channel sections, known channel indexes are preferred for labels (`17 -> Public`, `217 -> #test`) to avoid stale channel-name mismatches.
- Additional channel names are loaded from `COLLECTOR_CHANNEL_KEYS` when entries are provided as `label=hex` (for example `bot=<key>`).
- The collector keeps built-in keys for `Public` and `#test`, and merges any additional keys from `COLLECTOR_CHANNEL_KEYS`.
- Additional channel names are loaded from the `channels` database table (managed via CLI, API, or seed YAML).
- The collector keeps built-in keys for `Public` and `#test`, and merges any additional keys from enabled database channel rows.
## Location and Messages
@@ -0,0 +1,298 @@
# Channel Model: Database-Backed Decrypt Keys with Permission-Based Visibility
## Summary
Add a `Channel` database model to replace the `COLLECTOR_CHANNEL_KEYS` environment variable entirely. Channels store their name, secret key, computed channel hash, and a **visibility/permission level** (`public`, `member`, `operator`, `admin`). The collector loads keys from the database at startup and periodically refreshes them without restart. The web dashboard and API enforce permission-based visibility: "public" channels are visible to everyone (including logged-out users), "member" channels only to authenticated members, and so on.
A new **Channels page** in the web dashboard presents channels as cards (desktop and mobile), each showing a **QR code** for easy joining (`meshcore://channel/add?name=...&key=...`). The page is always visible regardless of OIDC status. When OIDC is enabled, admin users see inline channel management (add/edit/delete) and non-admin users see read-only cards filtered by their role. When OIDC is disabled, all channels are `public` by default, no admin UI is shown, and channels can only be configured via the seed mechanism.
## Background & Motivation
### Current State
Channel decryption keys flow through the system as follows:
1. `COLLECTOR_CHANNEL_KEYS` env var (comma/space-separated hex strings, e.g. `"MyChannel=ABC123...,Other=DEF456..."`)
2. Parsed by `CollectorSettings.collector_channel_keys_list` into a `list[str]` (`config.py:185-193`)
3. Passed to `create_subscriber(channel_keys=...)` and then `LetsMeshPacketDecoder(channel_keys=...)` (`subscriber.py:88-90`)
4. The decoder builds a `MeshCoreKeyStore` with `add_channel_secrets()` and uses it to decrypt GroupText (type 5) packets via the `meshcoredecoder` library (`letsmesh_decoder.py:63-68`)
5. Built-in keys (`Public`, `test`) are always included via `BUILTIN_CHANNEL_KEYS` (`letsmesh_decoder.py:32-35`)
6. The web app independently builds channel labels via `_build_channel_labels()` in `web/app.py:171-185`, reading the same env var to create a decoder instance just for label resolution
### Problems
- **Restart required**: Adding or changing a channel key requires editing `.env` and restarting the collector process.
- **No permission model**: All channels are visible to all users. There is no way to restrict sensitive channels (e.g., operator-only coordination channels) from public view.
- **No API/CLI management**: There is no way to add/remove channel keys at runtime.
- **No audit trail**: Keys exist only in config; there is no record of when a key was added or by whom.
- **Duplicated decoder construction**: The web app builds its own `LetsMeshPacketDecoder` just to resolve channel labels (`web/app.py:179-182`). With a database source, both collector and web can query the same `channels` table.
- **No user-facing channel info**: Users cannot discover available channels, see their names, or get QR codes to join them from their devices.
### Why Now
The collector already queries the database for cleanup and event persistence. The web app already has a QR code library (`qrcodejs`) loaded globally and used on the node detail page (`node-detail.js:361-374`) with the `meshcore://` URL scheme. The API proxy already has a role-based access control framework (`_build_endpoint_access` / `check_api_access` in `web/app.py:68-161`). Making channels a first-class database entity unblocks permission-based message filtering, a channels management page, and QR code distribution.
## Goals
- Introduce a `Channel` SQLAlchemy model with name, key, channel hash, **visibility/permission level**, and enabled flag
- Replace `COLLECTOR_CHANNEL_KEYS` env var entirely with database-backed channels
- Have the collector load keys from the `channels` table at startup and refresh periodically (no restart)
- Enforce permission-based visibility in messages view and dashboard: only show messages on channels the user has access to
- Provide a **Channels page** (`/channels`) that is always visible (with or without OIDC)
- When OIDC is enabled: show admin-only inline channel management; filter channel visibility by user role
- When OIDC is disabled: show all (public-only) channels read-only; channels configured only via seed
- Provide CLI commands and API endpoints for channel CRUD
- Support seeding channels from YAML (visibility defaults to `public`; no visibility field in seed data)
- Remove `COLLECTOR_CHANNEL_KEYS` and related config plumbing
## Non-Goals
- Changing the `meshcoredecoder` library or the decryption logic itself
- Storing node-specific private keys (this is about channel shared secrets)
- Encrypting channel keys at rest in the database
- End-to-end encryption or per-user channel access control beyond the role-based visibility model
- Filtering messages at the collector level (the collector decrypts all channels; filtering is done at the API/web layer)
- Admin UI for channels when OIDC is disabled (seed-only configuration)
## Requirements
### Functional Requirements
- **FR-1**: A `Channel` database model with fields:
- `id` (UUID primary key)
- `name` (String(100), unique, non-empty)
- `key_hex` (String(64), uppercase hex, unique — supports both AES-128 and AES-256 keys)
- `channel_hash` (String(2), computed: first byte of SHA-256 of `key_hex`)
- `visibility` (Enum: `public`, `member`, `operator`, `admin`; default `public`)
- `enabled` (Boolean, default `true`)
- `created_at`, `updated_at` (timestamps)
- **FR-2**: On startup, the collector queries all `Channel` rows where `enabled=true`, merges them with the hardcoded built-in keys (`Public`, `test` — both always available to the decoder), and builds the `MeshCoreKeyStore`. The `Public` built-in key always has `visibility=public` and cannot be overridden. The `test` built-in key is always loaded into the decoder for decryption, **but** test channel messages are **discarded by the normalizer by default** — they are only stored when a `test` channel row exists in the DB with `enabled=true` (added by an admin via CLI/API/seed). This replaces the `COLLECTOR_INCLUDE_TEST_CHANNEL` env var.
- **FR-3**: The collector periodically refreshes its key store from the database (configurable interval, default 5 minutes).
- **FR-4**: **Permission-based message visibility**: The API `/messages` endpoint accepts the user's role context (via OIDC X-User-Roles header from the web proxy, or API key auth for direct calls) and filters channel messages so that:
- No OIDC / OIDC disabled: all channel messages visible (all channels are `public` in this mode)
- Logged-out (OIDC enabled): only messages on `public` channels
- `member` role: messages on `public` + `member` channels
- `operator` role: messages on `public` + `member` + `operator` channels
- `admin` role: all messages (all channels)
- Direct messages (non-channel) remain governed by existing read access rules.
- **FR-4b**: **Dashboard channel activity filtering**: The `/dashboard/stats` and `/dashboard/message-activity` endpoints filter channel-related data by the same role-based visibility rules as `/messages`. Channel message counts, channel-specific message lists, and activity charts only include data from channels visible to the requesting user.
- **FR-5**: The dashboard and messages page channel filter dropdowns only show channels visible to the current user's role. When OIDC is disabled, all channels appear.
- **FR-6**: **Channels page** (`/channels`) -- always visible regardless of OIDC status:
- **OIDC disabled**: Read-only card grid showing all channels (all `public`). No add/edit/delete UI. No visibility badges.
- **OIDC enabled, no auth**: Read-only cards showing only `public` channels.
- **OIDC enabled, logged in**: Read-only cards showing channels up to the user's role level, with visibility badges.
- **OIDC enabled, admin**: Full management -- "Add Channel" button, edit/delete buttons per card, visibility select in forms.
- Card layout (responsive, works on desktop and mobile)
- Each card shows: channel name, channel hash, visibility badge (if OIDC enabled), QR code, enabled status
- QR code format: `meshcore://channel/add?name=<encoded_name>&key=<key_hex>`
- Card shows masked key (first/last 4 chars) with a reveal toggle for admins
- **FR-7**: **Admin inline channel management** (OIDC enabled + admin role only): Add/edit/delete channels via modal dialogs (following the tag editor pattern in `node-detail.js`). Only the `admin` role can perform these operations. This is not available when OIDC is disabled.
- **FR-8**: CLI commands: `meshcore-hub collector channel list`, `channel add --name X --key HEX --visibility public`, `channel remove --name X`, `channel enable/disable --name X`.
- **FR-9**: API endpoints:
- `GET /channels` -- list channels; filtered by user role visibility when OIDC enabled; returns all public channels when OIDC disabled
- `POST /channels` -- create (admin only, OIDC required)
- `PUT /channels/{id}` -- update (admin only, OIDC required)
- `DELETE /channels/{id}` -- delete (admin only, OIDC required)
- The web proxy (`_build_endpoint_access`) guards mutations behind the `admin` role; `GET` is `_OPEN`
- **FR-10**: Channel seeding from `${SEED_HOME}/channels.yaml` via `meshcore-hub collector seed`. Seed format does not include a `visibility` field -- seeded channels always get `visibility=public`. This is the only way to configure channels when OIDC is disabled.
- **FR-11**: `COLLECTOR_CHANNEL_KEYS` env var and related config (`collector_channel_keys`, `collector_channel_keys_list`) are removed. Migration guide documents the removal.
- **FR-12**: The web app's `_build_channel_labels()` in `web/app.py` is updated to query the `channels` table from the shared database instead of re-parsing the env var.
- **FR-13**: The `FEATURE_CHANNELS` feature flag controls page visibility. It does not depend on OIDC being enabled (unlike `feature_members` which requires OIDC). The page is available to all users when the flag is `true`.
### Technical Requirements
- **TR-1**: New model `Channel` in `src/meshcore_hub/common/models/channel.py`, exported from `models/__init__.py`.
- **TR-2**: Alembic migration to create the `channels` table.
- **TR-3**: Pydantic schemas for channel CRUD in `src/meshcore_hub/common/schemas/channels.py`.
- **TR-4**: `LetsMeshPacketDecoder.reload_keys(channel_keys: list[str])` method that rebuilds `MeshCoreKeyStore` and `_channel_names_by_hash` without discarding the decode cache. Thread-safe via atomic reference swap.
- **TR-5**: `Subscriber` gains a `_start_channel_refresh_scheduler()` method following the cleanup scheduler pattern (`subscriber.py:245-357`). Uses `DatabaseManager.async_session()` to query channels.
- **TR-6**: Message filtering at the API layer: the `/messages` route resolves the user's highest role, queries `channels` table for visible channel hashes, then filters channel messages. Filtering logic:
- OIDC disabled (no auth roles): no filtering — all channels treated as `public`.
- OIDC enabled: query DB for all channel hashes up to the user's visibility level. The query filter is: `(message_type != 'channel') OR (channel_idx IN (visible_hashes_as_ints)) OR (channel_idx NOT IN (all_known_hashes_as_ints))`. This shows direct messages always, channels at/below the user's visibility level, and unknown channels (treated as `public`). No pre-filtering means channels visible in the filter dropdown may differ from visible messages, but the `<10 channel count makes this negligible.
- **TR-7**: New SPA page module `src/meshcore_hub/web/static/js/spa/pages/channels.js` with card layout, QR code generation (reusing the `QRCode` library from `qrcodejs`), and admin modal editors.
- **TR-8**: Navigation placement: **All navigation surfaces** use the order `Messages → Channels → Members → Map`:
- `spa.html` desktop sidebar and mobile menu: insert Channels `<li>` between Messages and Members
- `app.js` dynamic nav: insert Channels `if (features.channels)` block between Messages and Members blocks
- `home.js` hero card grid: insert Channels `renderNavCard()` between Messages and Members cards
- Add CSS custom property `--color-channels` in `app.css` for hero card accent color
- **TR-9**: `FEATURE_CHANNELS` feature flag in `WebSettings` (default `true`), registered in the `features` property. Unlike `feature_members`, it does not gate on `oidc_enabled`.
- **TR-10**: The `channel_labels` config passed to the web frontend via `/config` endpoint stays in its current format (`{str(channel_idx): label}`). It is built from the `channels` DB table instead of parsing `COLLECTOR_CHANNEL_KEYS`, using a synchronous SQLAlchemy engine (SQLite allows concurrent reads). The existing `getChannelLabelsMap()` function in `components.js` continues to work unchanged. Channel visibility is fetched separately by the Channels page via `/api/v1/channels` — it does not go through the `/config` endpoint.
- **TR-11**: Remove `COLLECTOR_CHANNEL_KEYS` and `COLLECTOR_INCLUDE_TEST_CHANNEL` from `CollectorSettings`, remove `collector_channel_keys_list` property, remove `_parse_decoder_key_entries()` from `web/app.py`.
- **TR-12**: i18n keys for channel-related UI strings added to `en.json` and documented in `docs/i18n.md`.
- **TR-13**: Web proxy access mapping in `_build_endpoint_access()` updated:
- `"v1/channels": { "GET": _OPEN }` -- anyone can list
- `"v1/channels/": { "POST": frozenset({role_admin}), "PUT": frozenset({role_admin}), "DELETE": frozenset({role_admin}) }` -- admin-only mutations
- **TR-14**: `CHANNEL_REFRESH_INTERVAL_SECONDS` env var added to `CollectorSettings` (default `300`). Not in `WebSettings`.
## Implementation Plan
### Phase 1: Channel Model & Migration
- Create `src/meshcore_hub/common/models/channel.py`:
```python
class ChannelVisibility(str, Enum):
PUBLIC = "public"
MEMBER = "member"
OPERATOR = "operator"
ADMIN = "admin"
class Channel(Base, UUIDMixin, TimestampMixin):
__tablename__ = "channels"
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
key_hex: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
channel_hash: Mapped[str] = mapped_column(String(2), nullable=False)
visibility: Mapped[str] = mapped_column(String(20), default="public")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
```
- Export from `models/__init__.py`
- Generate Alembic migration: `meshcore-hub db revision --autogenerate -m "add channels table"`
- Unit tests for the model
### Phase 2: Decoder Reload Support
- Add `reload_keys(channel_keys: list[str])` to `LetsMeshPacketDecoder`
- Normalize new key list, rebuild `MeshCoreKeyStore`, update `_channel_names_by_hash`
- Preserve decode cache across reloads
- **Thread safety**: Add a `threading.Lock` (`_state_lock`) to guard access to `_key_store` and `_channel_names_by_hash`. The MQTT message callback thread reads these during decode; the refresh thread writes during reload. Lock is only held during the atomic swap (not during key normalization/KeyStore construction).
- Unit tests for reload behavior
### Phase 3: Collector DB Key Loading & Refresh
- On startup (`Subscriber.__init__` or `start()`), query `Channel` table for `enabled=true` rows via `self.db.session_scope()`
- Merge DB channels with the hardcoded built-in keys (`Public`, `test` — both always available to the decoder). The `test` key is always loaded into the decoder but test messages are discarded unless a DB row exists (see FR-2).
- The `_include_test_channel` flag moves from env var to a DB query: `self.db.async_session()->query(Channel).filter(name="test", enabled=True).first() is not None`. Evaluated once at startup and on refresh.
- Add `_start_channel_refresh_scheduler()` to `Subscriber`, following the cleanup scheduler pattern (`subscriber.py:245-357`)
- Add `channel_refresh_interval_seconds` field to `CollectorSettings` (env var `CHANNEL_REFRESH_INTERVAL_SECONDS`, default `300`)
- Pass interval to `Subscriber.__init__` alongside other scheduler params (cleanup_enabled, cleanup_retention_days, etc.)
- Remove `channel_keys` parameter from `Subscriber.__init__`, `create_subscriber()`, and `run_collector()`
- Remove `COLLECTOR_CHANNEL_KEYS` from `CollectorSettings` and related parsing
- Integration tests
### Phase 4: API Endpoints & Message Filtering
- Create `src/meshcore_hub/common/schemas/channels.py`: `ChannelCreate`, `ChannelRead`, `ChannelUpdate`, `ChannelList`
- Create `src/meshcore_hub/api/routes/channels.py`:
- `GET /channels` -- list channels, filtered by user role. When no OIDC roles are present (OIDC disabled or not logged in), returns only `public` channels
- `POST /channels` -- create (admin only, uses `RequireAdmin` dependency on API side)
- `PUT /channels/{id}` -- update (admin only)
- `DELETE /channels/{id}` -- delete (admin only)
- Add role-aware message filtering to `GET /messages`:
- Resolve user's highest role from auth context (X-User-Roles header or API key)
- When no roles available (OIDC disabled): no filtering (all channels visible)
- When OIDC enabled: query visible channel hashes from `channels` table based on role hierarchy (role → visibility levels up to that role)
- Channel hash to channel_idx conversion: `channel_idx = int(channel_hash, 16)` (both are 0-255)
- Query filter: `(message_type != 'channel') OR (channel_idx IN (visible_idxs)) OR (channel_idx NOT IN (all_known_idxs))`
- Unknown channel hashes (not in DB) are treated as `public` and pass through the third clause
- Add role-aware channel filtering to `GET /dashboard/stats` and `/dashboard/message-activity`:
- Resolve user's highest role from auth context
- Filter channel counts and channel activity lists by visible channels using the same visibility logic as `/messages`
- When OIDC disabled: show all channels
- Update `_build_channel_labels()` in `web/app.py` to query DB using a synchronous SQLAlchemy engine against the shared SQLite database. This is safe because SQLite allows concurrent readers. The function is called once at startup; results are stored in `app.state.channel_labels`. The existing format (`{str(channel_idx): label}`) is preserved.
- Add entries to `_build_endpoint_access()`:
- `"v1/channels": { "GET": _OPEN }`
- `"v1/channels/": { "POST": frozenset({role_admin}), "PUT": frozenset({role_admin}), "DELETE": frozenset({role_admin}) }`
- Register router in `api/routes/__init__.py`
- Tests for all endpoints and filtering
### Phase 5: CLI Commands
- Add `channel` subgroup to collector CLI in `cli.py`
- `meshcore-hub collector channel list` -- name, masked key, hash, visibility, enabled
- `meshcore-hub collector channel add --name NAME --key HEX --visibility public`
- `meshcore-hub collector channel remove --name NAME`
- `meshcore-hub collector channel enable/disable --name NAME`
- Remove `channel_keys` and `include_test_channel` params from `_run_collector_service()`
- Tests for each command
### Phase 6: Web Dashboard -- Channels Page
- Create `src/meshcore_hub/web/static/js/spa/pages/channels.js`:
- Fetch `/api/v1/channels` (API returns only channels the user can see)
- Render responsive card grid (DaisyUI `card` component)
- Each card: channel name, channel hash badge, visibility badge (OIDC only), QR code, masked key
- QR code: `meshcore://channel/add?name=<encoded>&key=<hex>` using `QRCode` library
- **OIDC disabled**: read-only cards, no visibility badges, no add/edit/delete controls
- **OIDC enabled, admin**: "Add Channel" button, edit/delete buttons per card
- **OIDC enabled, non-admin**: read-only cards filtered by role
- Add/edit modal (following tag editor pattern from `node-detail.js`): name, key (hex), visibility select, enabled toggle -- only rendered when `hasRole('admin')`
- Delete confirmation modal (following `tagDeleteModal` pattern)
- Use `getConfig().oidc_enabled` to conditionally show/hide admin controls and visibility badges
- **Navigation ordering** -- Channels appears **after Messages and before Members** in all navigation surfaces:
- `spa.html` desktop sidebar and mobile menu: insert Channels `<li>` between Messages and Members
- `app.js` dynamic nav (`renderNavItems()`): insert Channels `if (features.channels !== false)` block between Messages and Members blocks
- `home.js` hero card grid: insert Channels `renderNavCard()` between Messages and Members cards
- Add CSS custom property `--color-channels` in `app.css` for hero card accent color
- **Icon**: Use the existing `iconChannel` SVG function from `icons.js` (hash/# icon, already defined at `icons.js:77-79`). Import it in `channels.js`, `home.js`, `app.js`, and use it inline in `spa.html` nav links.
- Register route in `app.js`: `router.addRoute('/channels', pageHandler(pages.channels))`
- No OIDC dependency in route registration (unlike members which gates on `features.members`)
- Add `FEATURE_CHANNELS` feature flag to `WebSettings` -- does NOT gate on `oidc_enabled`:
```python
feature_channels: bool = Field(default=True, description="Enable the /channels page")
# In features property:
"channels": self.feature_channels, # no oidc_enabled guard
```
- Add page title handling in `updatePageTitle()` in `app.js`
- Add i18n keys to `en.json` (including `entities.channels`, `entities.channel`) and update `docs/i18n.md`
- Tests in `tests/test_web/`
### Phase 7: Seeding, Config Cleanup & Docs
- Add `channels.yaml` support to seed importer in `cli.py`
- Shorthand format: `name: HEX` — value is a hex string, treated as the channel key
- Expanded format: `name: { key: HEX, enabled: true }` — value is a dict
- The parser distinguishes by type: `str` → shorthand (treat value as `key_hex`), `dict` → expanded (read `key` and optional `enabled` fields)
- No `visibility` field in seed format — always defaults to `public`
- This is the primary configuration path when OIDC is disabled
- Remove `COLLECTOR_CHANNEL_KEYS` and `COLLECTOR_INCLUDE_TEST_CHANNEL` from:
- `CollectorSettings` in `config.py`
- `collector_channel_keys_list` property
- `_parse_decoder_key_entries()` in `web/app.py`
- `_run_collector_service()` in `cli.py`
- `Subscriber.__init__`, `create_subscriber()`, `run_collector()` signatures
- `AGENTS.md` env var list
- `.env.example` (lines 204 and 208)
- Update `docs/seeding.md` with channels.yaml documentation (emphasize: visibility always `public`, admin-only channels require OIDC + API/CLI)
- Update `docs/upgrading.md` with migration guide:
- Run `meshcore-hub db upgrade`
- Convert any `COLLECTOR_CHANNEL_KEYS` values to `channels.yaml` seed file or DB rows via CLI
- Remove env var from `.env`
- Note: existing seeded channels will be `public` visibility
- Update `AGENTS.md` with new model, page, feature flag, and API routes
- Update `SCHEMAS.md` if channel event schemas are affected
## Review
**Status**: Approved with Changes
**Reviewed**: 2026-05-19
### Resolutions
- **Nav ordering**: All navigation surfaces use `Messages → Channels → Members → Map` (not `Messages → Channels → Map → Members`). Updated TR-8 and Phase 6.
- **`key_hex` column size**: Changed from `String(32)` to `String(64)` to support both AES-128 and AES-256 keys. Updated FR-1 and Phase 1 model.
- **Frontend config format**: `channel_labels` stays as `{str(idx): label}` — unchanged format. Channels page fetches visibility from `/api/v1/channels` directly. Updated TR-10.
- **`_build_channel_labels()` sync approach**: Uses a synchronous SQLAlchemy engine for a one-time startup query against the shared SQLite database (safe because SQLite allows concurrent readers). Updated Phase 4.
- **Dashboard channel filtering**: Yes, dashboard channel activity is filtered by role visibility (consistent with `/messages`). Added FR-4b and updated Phase 4.
- **Thread safety**: Specified `threading.Lock` (`_state_lock`) on decoder for atomic swap between MQTT callback and refresh threads. Updated Phase 2.
- **`CHANNEL_REFRESH_INTERVAL_SECONDS`**: Added as `CollectorSettings` field (not `WebSettings`). Added TR-14 and updated Phase 3.
- **Message filtering logic**: Changed from simple `IN (...)` to three-clause filter: direct messages always visible, known channels filtered by visibility, unknown channels treated as `public`. Updated TR-6 and Phase 4.
- **Seed format**: Parser distinguishes shorthand (`str` → key_hex) vs expanded (`dict` → read `key` and optional `enabled`). No `visibility` field. Clarified in Phase 7.
### Remaining Action Items
- **QR code URL format**: The `meshcore://channel/add?name=...&key=...` scheme is proposed by analogy with the existing contact QR. Confirm with MeshCore app devs whether this scheme is supported or planned. (Baked into plan as-is; QR codes work if/when app supports them.)
- **Test channel excluded by default**: `test` built-in key always loaded into decoder (for decryption), but normalizer discards test messages unless a `Channel` row with `name="test"` and `enabled=true` exists in DB. NOT created automatically — admin must explicitly add it. Replaces `COLLECTOR_INCLUDE_TEST_CHANNEL`. Updated FR-2 and Phase 3.
- **Existing test updates**: Tests that reference `channel_labels["17"] == "Public"` etc. must continue to pass. Since format is unchanged, they should, but verify during Phase 4.
## References
- `src/meshcore_hub/collector/letsmesh_decoder.py` -- decoder with static key init, `BUILTIN_CHANNEL_KEYS`, `channel_labels_by_index()`
- `src/meshcore_hub/collector/subscriber.py` -- subscriber with cleanup scheduler pattern (thread + async session) to follow
- `src/meshcore_hub/common/models/node_tag.py` -- model pattern (UUIDMixin, TimestampMixin)
- `src/meshcore_hub/common/config.py:141-193` -- current `COLLECTOR_CHANNEL_KEYS` config (to be removed)
- `src/meshcore_hub/web/app.py:68-161` -- `_build_endpoint_access()` and `check_api_access()` for proxy auth guards
- `src/meshcore_hub/web/app.py:164-185` -- `_build_channel_labels()` (to be updated to query DB)
- `src/meshcore_hub/web/static/js/spa/pages/node-detail.js:361-374` -- QR code pattern using `QRCode` library and `meshcore://` scheme
- `src/meshcore_hub/web/static/js/spa/pages/node-detail.js:25-71` -- modal dialog patterns for tag edit/delete
- `src/meshcore_hub/api/auth.py` -- auth dependencies (`RequireRead`, `RequireAdmin`, `require_operator_or_admin`)
- `src/meshcore_hub/web/static/js/spa/components.js` -- `hasRole()`, `getChannelLabelsMap()`, `resolveChannelLabel()`
@@ -0,0 +1,256 @@
# Tasks: Channel Model — Database-Backed Decrypt Keys with Permission-Based Visibility
> Generated from `plan.md` on 2026-05-19
## 1. Database Schema & Migration
- [ ] 1.1 Create `Channel` SQLAlchemy model
- [ ] 1.1.1 Define `ChannelVisibility` enum (`public`, `member`, `operator`, `admin`)
- [ ] 1.1.2 Create `Channel` class in `src/meshcore_hub/common/models/channel.py` (fields: `id`, `name`, `key_hex`, `channel_hash`, `visibility`, `enabled`, `created_at`, `updated_at`)
- [ ] 1.1.3 `key_hex` must be `String(64)` (supports AES-128 and AES-256 keys)
- [ ] 1.1.4 `channel_hash` must be `String(2)` (first byte of SHA-256 of `key_hex`, uppercase hex)
- [ ] 1.1.5 `name` must be `String(100)`, unique, non-nullable
- [ ] 1.1.6 Export `Channel`, `ChannelVisibility` from `models/__init__.py`
- [ ] 1.2 Generate Alembic migration
- [ ] 1.2.1 Run `meshcore-hub db revision --autogenerate -m "add channels table"`
- [ ] 1.2.2 Review generated migration for correctness (unique constraints on `name` and `key_hex`)
- [ ] 1.2.3 Test migration: `meshcore-hub db upgrade` and verify table creation
- [ ] 1.3 Create Pydantic schemas
- [ ] 1.3.1 Create `src/meshcore_hub/common/schemas/channels.py` with `ChannelCreate`, `ChannelRead`, `ChannelUpdate`, `ChannelList`
- [ ] 1.3.2 `ChannelCreate`: validate `name`, `key_hex` (uppercase hex, 32 or 64 chars), optional `visibility` (default `public`), optional `enabled` (default `true`)
- [ ] 1.3.3 `ChannelRead`: include `id`, `name`, `channel_hash`, `visibility`, `enabled`, `created_at`, `updated_at`, but NOT `key_hex` (mask first/last 4 chars for read)
- [ ] 1.3.4 `ChannelUpdate`: all fields optional except `name` immutable
- [ ] 1.3.5 Add `masked_key` computed property on `ChannelRead` (e.g. `"ABCD...EF01"`)
- [ ] 1.4 Write unit tests for the model and schemas
- [ ] 1.4.1 Test `Channel` model instantiation and defaults
- [ ] 1.4.2 Test unique constraint enforcement on `name` and `key_hex`
- [ ] 1.4.3 Test `ChannelCreate` schema validation (valid keys, invalid keys, name length)
- [ ] 1.4.4 Test `ChannelRead.masked_key` formatting
## 2. Decoder Reload Support
- [ ] 2.1 Add `reload_keys()` method to `LetsMeshPacketDecoder`
- [ ] 2.1.1 Add `threading.Lock` (`_state_lock`) to the decoder class
- [ ] 2.1.2 Implement `reload_keys(channel_keys: list[str])` — normalize new keys, rebuild `MeshCoreKeyStore`, update `_channel_names_by_hash`
- [ ] 2.1.3 Preserve the decode cache (`_decode_cache`) across reloads
- [ ] 2.1.4 Use `_state_lock` for atomic swap of `_key_store` and `_channel_names_by_hash` (hold lock only during swap, not during key normalization/KeyStore construction)
- [ ] 2.1.5 Update `channel_labels_by_index()` and `resolve_channel_name()` to use `_state_lock` when reading shared state
- [ ] 2.2 Write unit tests for reload behavior
- [ ] 2.2.1 Test that reload with new keys enables decryption of messages on the new channel
- [ ] 2.2.2 Test that decode cache persists across reloads
- [ ] 2.2.3 Test thread safety: concurrent decode reads while reload is in progress (mock/threading test)
## 3. Collector DB Key Loading & Refresh
- [ ] 3.1 Update `Subscriber` to load keys from database on startup
- [ ] 3.1.1 Query all `enabled=true` channels from DB via `self.db.session_scope()`
- [ ] 3.1.2 Merge DB channels with hardcoded built-in keys (`Public`, `test` — always loaded into decoder)
- [ ] 3.1.3 Move `_include_test_channel` from env var to DB query: check if `Channel(name="test", enabled=True)` row exists
- [ ] 3.1.4 Pass merged key list to decoder (replacing old `channel_keys` constructor param)
- [ ] 3.2 Update `letsmesh_normalizer.py` test channel filter
- [ ] 3.2.1 Replace env-var-based `_include_test_channel` check with DB lookup in the normalizer
- [ ] 3.2.2 Test messages are always decrypted (key in decoder), but discarded by normalizer unless DB row exists
- [ ] 3.3 Add `_start_channel_refresh_scheduler()` to `Subscriber`
- [ ] 3.3.1 Follow the cleanup scheduler pattern (`subscriber.py:245-357`): daemon thread + async session
- [ ] 3.3.2 Query enabled channels from DB on each cycle
- [ ] 3.3.3 Call `decoder.reload_keys()` with updated key list
- [ ] 3.3.4 Handle graceful shutdown (stop event)
- [ ] 3.4 Add `CHANNEL_REFRESH_INTERVAL_SECONDS` to `CollectorSettings`
- [ ] 3.4.1 Add field to `CollectorSettings` in `config.py` (default `300`, env var `CHANNEL_REFRESH_INTERVAL_SECONDS`)
- [ ] 3.4.2 Pass interval to `Subscriber.__init__` alongside other scheduler params
- [ ] 3.5 Remove `channel_keys` from collector plumbing
- [ ] 3.5.1 Remove `channel_keys` param from `Subscriber.__init__`, `create_subscriber()`, `run_collector()`
- [ ] 3.5.2 Remove `COLLECTOR_CHANNEL_KEYS` from `CollectorSettings`
- [ ] 3.5.3 Remove `collector_channel_keys_list` property from `CollectorSettings`
- [ ] 3.5.4 Remove `COLLECTOR_INCLUDE_TEST_CHANNEL` from `CollectorSettings`
- [ ] 3.6 Write integration tests
- [ ] 3.6.1 Test that collector loads keys from DB on startup
- [ ] 3.6.2 Test that collector refreshes keys on schedule
- [ ] 3.6.3 Test that test channel messages are discarded when no DB row exists
- [ ] 3.6.4 Test that test channel messages are stored when DB row with `enabled=true` exists
## 4. API Endpoints & Message Filtering
- [ ] 4.1 Create API routes for channels
- [ ] 4.1.1 Create `src/meshcore_hub/api/routes/channels.py`
- [ ] 4.1.2 Implement `GET /channels` — list channels filtered by user role visibility; when no OIDC roles, return only `public` channels
- [ ] 4.1.3 Implement `POST /channels` — create channel (admin only)
- [ ] 4.1.4 Implement `PUT /channels/{id}` — update channel (admin only, `name` immutable)
- [ ] 4.1.5 Implement `DELETE /channels/{id}` — delete channel (admin only)
- [ ] 4.1.6 Register router in `api/routes/__init__.py`
- [ ] 4.2 Add role-aware message filtering to `GET /messages`
- [ ] 4.2.1 Resolve user's highest role from auth context (X-User-Roles header or API key)
- [ ] 4.2.2 When no roles available (OIDC disabled): no filtering (all channels treated as public)
- [ ] 4.2.3 When OIDC enabled: query visible channel hashes from `channels` table based on role hierarchy
- [ ] 4.2.4 Build visibility set: compute `channel_idx = int(channel_hash, 16)` for each visible channel
- [ ] 4.2.5 Build full known set: all `channel_idx` values from all channels in DB (for "unknown = public" clause)
- [ ] 4.2.6 Apply three-clause filter: `(message_type != 'channel') OR (channel_idx IN (visible_idxs)) OR (channel_idx NOT IN (all_known_idxs))`
- [ ] 4.3 Add role-aware channel filtering to dashboard endpoints
- [ ] 4.3.1 Resolve user's highest role from auth context (same logic as `/messages`)
- [ ] 4.3.2 Filter `GET /dashboard/stats` channel message counts by visible channels
- [ ] 4.3.3 Filter `GET /dashboard/message-activity` channel activity lists by visible channels
- [ ] 4.3.4 When OIDC disabled: show all channels (no filtering)
- [ ] 4.4 Update `_build_channel_labels()` in `web/app.py`
- [ ] 4.4.1 Replace env-var parsing with database query using a synchronous SQLAlchemy engine
- [ ] 4.4.2 Maintain existing format: `{str(channel_idx): label}`
- [ ] 4.4.3 Include both built-in `Public` and all `enabled=true` channels from DB
- [ ] 4.4.4 Remove `_parse_decoder_key_entries()` helper function
- [ ] 4.5 Update web proxy `_build_endpoint_access()`
- [ ] 4.5.1 Add `"v1/channels": { "GET": _OPEN }` — anyone can list (filtering is server-side)
- [ ] 4.5.2 Add `"v1/channels/": { "POST": frozenset({role_admin}), "PUT": frozenset({role_admin}), "DELETE": frozenset({role_admin}) }` — admin-only mutations
- [ ] 4.5.3 Verify longest-prefix matching: `v1/channels/` takes precedence for POST/PUT/DELETE over `v1/channels`
- [ ] 4.6 Write tests for API endpoints and filtering
- [ ] 4.6.1 Test `GET /channels` returns only public channels when no auth
- [ ] 4.6.2 Test `GET /channels` returns appropriate channels per role
- [ ] 4.6.3 Test `POST/PUT/DELETE /channels` restricted to admin only
- [ ] 4.6.4 Test message filtering: only visible channel messages returned per role
- [ ] 4.6.5 Test message filtering: unknown channels pass through (treated as public)
- [ ] 4.6.6 Test message filtering: direct messages always visible
- [ ] 4.6.7 Test dashboard channel counts filtered by role
- [ ] 4.6.8 Verify existing tests referencing `channel_labels["17"] == "Public"` still pass
## 5. CLI Commands
- [ ] 5.1 Add `channel` subgroup to collector CLI
- [ ] 5.1.1 Create `channel` Click group in `src/meshcore_hub/collector/cli.py`
- [ ] 5.1.2 Implement `meshcore-hub collector channel list` — table output: name, masked key, hash, visibility, enabled
- [ ] 5.1.3 Implement `meshcore-hub collector channel add --name NAME --key HEX --visibility public` — create channel row
- [ ] 5.1.4 Implement `meshcore-hub collector channel remove --name NAME` — delete channel by name
- [ ] 5.1.5 Implement `meshcore-hub collector channel enable --name NAME` — set `enabled=true`
- [ ] 5.1.6 Implement `meshcore-hub collector channel disable --name NAME` — set `enabled=false`
- [ ] 5.1.7 Remove `channel_keys` and `include_test_channel` params from `_run_collector_service()`
- [ ] 5.2 Write tests for CLI commands
- [ ] 5.2.1 Test `channel list` output format
- [ ] 5.2.2 Test `channel add` creates row with correct fields and `channel_hash` computed
- [ ] 5.2.3 Test `channel add` rejects invalid keys (non-hex, wrong length)
- [ ] 5.2.4 Test `channel add` rejects duplicate names
- [ ] 5.2.5 Test `channel remove` deletes row
- [ ] 5.2.6 Test `channel enable/disable` toggles `enabled` flag
- [ ] 5.2.7 Test that old `--channel-keys` option is removed (CLI help output)
## 6. Web Dashboard — Channels Page
- [ ] 6.1 Add `FEATURE_CHANNELS` feature flag
- [ ] 6.1.1 Add `feature_channels: bool` to `WebSettings` (default `true`, env var `FEATURE_CHANNELS`)
- [ ] 6.1.2 Add `"channels": self.feature_channels` to `features` property (NO `oidc_enabled` guard)
- [ ] 6.1.3 Expose in `/config` endpoint response
- [ ] 6.2 Create Channels page module
- [ ] 6.2.1 Create `src/meshcore_hub/web/static/js/spa/pages/channels.js`
- [ ] 6.2.2 Implement `render(container, params, router)` — fetch `/api/v1/channels`, render card grid
- [ ] 6.2.3 Return a cleanup function if any resources are created
- [ ] 6.2.4 Responsive card layout: DaisyUI `card` component, grid adapts to screen width
- [ ] 6.3 Channel card UI
- [ ] 6.3.1 Card content: channel name, channel hash badge, visibility badge (OIDC only), QR code, masked key
- [ ] 6.3.2 QR code generation: `new QRCode(canvas, { text: "meshcore://channel/add?name=<encoded>&key=<hex>" })` using existing `qrcodejs` library
- [ ] 6.3.3 Masked key display: `{first4}...{last4}` with reveal toggle for admin users
- [ ] 6.3.4 Visibility badge: colored badge (e.g., green=public, yellow=member, orange=operator, red=admin)
- [ ] 6.3.5 Use `getConfig().oidc_enabled` to conditionally show admin controls and visibility badges
- [ ] 6.4 Admin inline channel management modals
- [ ] 6.4.1 Add Channel modal (follow tag editor pattern from `node-detail.js:25-71`)
- [ ] 6.4.2 Fields: name (text), key_hex (text, validated as hex), visibility (select: public/member/operator/admin), enabled (toggle)
- [ ] 6.4.3 Edit Channel modal: pre-populate fields, name read-only
- [ ] 6.4.4 Delete confirmation modal (follow `tagDeleteModal` pattern)
- [ ] 6.4.5 All modal actions gated behind `hasRole('admin')` — only rendered when admin
- [ ] 6.5 Conditional rendering modes
- [ ] 6.5.1 OIDC disabled: read-only cards, all channels public, no visibility badges, no add/edit/delete UI
- [ ] 6.5.2 OIDC enabled, not logged in: read-only cards, only public channels shown
- [ ] 6.5.3 OIDC enabled, non-admin: read-only cards, channels filtered by role, visibility badges shown
- [ ] 6.5.4 OIDC enabled, admin: full management — "Add Channel" button, edit/delete per card, visibility select in forms
- [ ] 6.6 Update channel filter dropdowns across the SPA
- [ ] 6.6.1 Dashboard channel filter: only show channels visible to user's role
- [ ] 6.6.2 Messages page channel filter: only show channels visible to user's role
- [ ] 6.6.3 When OIDC disabled, all channels appear in both dropdowns
- [ ] 6.7 Navigation placement — Channels between Messages and Members on all surfaces
- [ ] 6.7.1 `spa.html` desktop sidebar: insert `<li>` for `/channels` with `iconChannel()` between Messages and Members
- [ ] 6.7.2 `spa.html` mobile menu: insert `<li>` for `/channels` between Messages and Members
- [ ] 6.7.3 `app.js` dynamic nav: insert `if (features.channels !== false)` block between Messages and Members blocks
- [ ] 6.7.4 `app.js` route registration: `router.addRoute('/channels', pageHandler(pages.channels))` (no OIDC gate)
- [ ] 6.7.5 `app.js` `updatePageTitle()`: add 'channels' case
- [ ] 6.7.6 `home.js` hero card grid: insert `renderNavCard()` for Channels between Messages and Members
- [ ] 6.7.7 Add `--color-channels` CSS custom property in `app.css` for hero card accent color
- [ ] 6.7.8 Import `iconChannel` from `icons.js` in `channels.js`, `home.js`, `app.js`; use inline in `spa.html`
- [ ] 6.8 i18n
- [ ] 6.8.1 Add channel-related keys to `src/meshcore_hub/web/static/locales/en.json`:
- `entities.channel`, `entities.channels`
- `channels.title`, `channels.add_channel`, `channels.edit_channel`, `channels.delete_channel`
- `channels.name_label`, `channels.key_label`, `channels.visibility_label`, `channels.enabled_label`
- `channels.channel_hash_label`, `channels.qr_code_label`
- `channels.visibility_public`, `channels.visibility_member`, `channels.visibility_operator`, `channels.visibility_admin`
- `common.no_entity_found` for channels (composed pattern)
- [ ] 6.8.2 Add tests for new i18n keys in `tests/test_common/test_i18n.py`
- [ ] 6.8.3 Update `docs/i18n.md` with new keys and usage context
## 7. Seeding, Config Cleanup & Docs
- [ ] 7.1 Add `channels.yaml` seed support
- [ ] 7.1.1 Add channels seeding to `seed` command in `src/meshcore_hub/collector/cli.py`
- [ ] 7.1.2 Read `${SEED_HOME}/channels.yaml`
- [ ] 7.1.3 Parse shorthand format: `name: HEX` — value is `str`, treated as `key_hex`
- [ ] 7.1.4 Parse expanded format: `name: { key: HEX, enabled: true }` — value is `dict`
- [ ] 7.1.5 Parser distinguishes by type: `isinstance(value, str)` vs `isinstance(value, dict)`
- [ ] 7.1.6 No `visibility` field — always defaults to `public`
- [ ] 7.1.7 Upsert logic: update existing channel by `name`, insert new ones; never delete
- [ ] 7.2 Remove `COLLECTOR_CHANNEL_KEYS` and `COLLECTOR_INCLUDE_TEST_CHANNEL` from all files
- [ ] 7.2.1 `src/meshcore_hub/common/config.py``CollectorSettings`
- [ ] 7.2.2 `src/meshcore_hub/web/app.py``_parse_decoder_key_entries()`, `_build_channel_labels()`
- [ ] 7.2.3 `src/meshcore_hub/collector/cli.py``_run_collector_service()`
- [ ] 7.2.4 `src/meshcore_hub/collector/subscriber.py``Subscriber.__init__`, `create_subscriber()`, `run_collector()`
- [ ] 7.2.5 `.env.example` — lines 204, 208
- [ ] 7.2.6 `AGENTS.md` — env var list
- [ ] 7.3 Update documentation
- [ ] 7.3.1 Update `docs/seeding.md` with `channels.yaml` format, examples, and note that visibility is always `public`
- [ ] 7.3.2 Update `docs/upgrading.md` with migration guide:
- Run `meshcore-hub db upgrade`
- Convert `COLLECTOR_CHANNEL_KEYS` values to `channels.yaml` or DB rows via CLI
- Remove env var from `.env`
- Note all seeded channels are `public`
- [ ] 7.3.3 Update `AGENTS.md`: add `Channel` model to model list, `FEATURE_CHANNELS` to feature flags, `/channels` to API routes
- [ ] 7.3.4 Update `SCHEMAS.md` if channel event schemas are affected
- [ ] 7.3.5 Create example `channels.yaml` in `example/seed/channels.yaml`
## 8. Verification
- [ ] 8.1 Code quality
- [ ] 8.1.1 Run `pre-commit run --all-files` and fix all issues
- [ ] 8.1.2 Ensure no `except ValueError, TypeError:` patterns (use parenthesized tuples)
- [ ] 8.2 Component tests
- [ ] 8.2.1 Run `pytest tests/test_collector/` for collector-side changes
- [ ] 8.2.2 Run `pytest tests/test_api/` for API endpoints and message filtering
- [ ] 8.2.3 Run `pytest tests/test_web/` for web dashboard changes
- [ ] 8.2.4 Run `pytest tests/test_common/` for model and schema changes
- [ ] 8.2.5 Run `pytest tests/test_common/test_i18n.py` for i18n keys
- [ ] 8.2.6 Run full `pytest` to verify no regressions
- [ ] 8.3 Manual verification
- [ ] 8.3.1 Start collector with empty channels table — verify only `Public` channel messages decrypted and stored
- [ ] 8.3.2 Add a channel via CLI — verify collector picks it up at next refresh without restart
- [ ] 8.3.3 Seed channels from `channels.yaml` — verify rows created with `visibility=public`
- [ ] 8.3.4 Verify dashboard loads without errors and no features are lost
- [ ] 8.3.5 Verify Channels page renders correctly in all modes (OIDC disabled, logged out, admin)
- [ ] 8.3.6 Verify message filtering by role (different roles see different channel messages)
- [ ] 8.3.7 Verify QR code renders on channel cards
+48 -1
View File
@@ -13,12 +13,14 @@ docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile seed up
This imports data from the following files (if they exist):
- `{SEED_HOME}/node_tags.yaml` - Node tag definitions
- `{SEED_HOME}/channels.yaml` - Channel decryption keys
## Directory Structure
```
seed/ # SEED_HOME (seed data files)
── node_tags.yaml # Node tags for import
── node_tags.yaml # Node tags for import
└── channels.yaml # Channel keys for import
data/ # DATA_HOME (runtime data)
└── collector/
@@ -59,3 +61,48 @@ Tag values can be:
```
Supported types: `string`, `number`, `boolean`
## Channels
Channel keys are used to decrypt encrypted mesh messages. They are stored in the database and loaded by the collector at startup, with periodic refresh.
### Channels YAML Format
Channels support two formats:
**Shorthand** (name → hex key string):
```yaml
MyChannel: AABBCCDD11223344AABBCCDD11223344
```
**Expanded** (name → dict with options):
```yaml
MyChannel:
key: AABBCCDD11223344AABBCCDD11223344
enabled: true
```
Key rules:
- Keys must be uppercase hex, 32 characters (AES-128) or 64 characters (AES-256)
- Seeded channels always have `visibility: public` — to set member/operator/admin visibility, use the CLI or API
- The `Public` and `test` built-in keys are always loaded into the decoder regardless of database contents
- Test channel messages are only stored when a `test` channel row exists in the database with `enabled: true`
### Managing Channels via CLI
```bash
# List all channels
meshcore-hub collector channel list
# Add a channel
meshcore-hub collector channel add --name MyChannel --key AABBCCDD11223344AABBCCDD11223344
# Enable/disable a channel
meshcore-hub collector channel enable --name MyChannel
meshcore-hub collector channel disable --name MyChannel
# Remove a channel
meshcore-hub collector channel remove --name MyChannel
```
+37
View File
@@ -2,6 +2,43 @@
This guide covers upgrading from a previous MeshCore Hub release to the current version. Check the relevant version section below before upgrading.
## v0.13.0
### Database-Backed Channel Keys
Channel decryption keys are now managed via the `channels` database table instead of the `COLLECTOR_CHANNEL_KEYS` environment variable. This enables runtime key management, permission-based visibility, and a Channels dashboard page.
**New database table: `channels`**
| Column | Type | Description |
|--------|------|-------------|
| `id` | `VARCHAR(36), PK` | UUID primary key |
| `name` | `VARCHAR(100), UNIQUE` | Channel display name |
| `key_hex` | `VARCHAR(64), UNIQUE` | Uppercase hex key (32 or 64 chars) |
| `channel_hash` | `VARCHAR(2)` | First byte of SHA-256 of key |
| `visibility` | `VARCHAR(20)` | `public`, `member`, `operator`, or `admin` |
| `enabled` | `BOOLEAN` | Whether the channel is active |
| `created_at`, `updated_at` | `DATETIME` | Timestamps |
**Removed environment variables:**
- `COLLECTOR_CHANNEL_KEYS` — replaced by database channels table
- `COLLECTOR_INCLUDE_TEST_CHANNEL` — replaced by presence of a `test` channel row in the database
**New environment variables:**
- `CHANNEL_REFRESH_INTERVAL_SECONDS` — seconds between key refresh (default: `300`)
- `FEATURE_CHANNELS` — enable/disable the /channels page (default: `true`)
**Migration steps:**
1. Run `meshcore-hub db upgrade` to create the `channels` table
2. Convert any `COLLECTOR_CHANNEL_KEYS` values to either:
- A `channels.yaml` seed file in `SEED_HOME` (see `docs/seeding.md`)
- Database rows via CLI: `meshcore-hub collector channel add --name X --key HEX`
3. Remove `COLLECTOR_CHANNEL_KEYS` and `COLLECTOR_INCLUDE_TEST_CHANNEL` from your `.env`
4. If you previously relied on test channel messages, add a test channel: `meshcore-hub collector channel add --name test --key 9CD8FCF22A47333B591D96A2B848B73F`
**Test channel behavior change:** Test channel messages (channel_idx 217) are now discarded by default unless a `test` channel row exists in the database with `enabled=true`. Previously this was controlled by `COLLECTOR_INCLUDE_TEST_CHANNEL`.
## v0.12.0
### Advertisement Route Type & Deduplication Improvements
+14
View File
@@ -0,0 +1,14 @@
# Channel seed data for MeshCore Hub
#
# Format options:
# Shorthand: name: HEX_KEY
# Expanded: name: { key: HEX_KEY, enabled: true }
#
# Visibility is always 'public' for seeded channels.
# To set member/operator/admin visibility, use the CLI or API.
#
# Example:
# MyChannel: AABBCCDD11223344AABBCCDD11223344
# PrivateChannel:
# key: 11223344AABBCCDD11223344AABBCCDD11223344AABBCCDD11223344AABBCCDD
# enabled: true
@@ -0,0 +1,68 @@
"""Shared channel visibility helpers for API routes.
Resolves user roles from proxy-injected headers and determines which
channel indices are visible based on channel visibility levels.
"""
from fastapi import Request
from sqlalchemy import select
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.common.models.channel import Channel
VISIBILITY_LEVELS = {"public": 0, "member": 1, "operator": 2, "admin": 3}
def resolve_user_role(request: Request) -> str | None:
"""Resolve the user's highest role from X-User-Roles header."""
roles_header = request.headers.get("x-user-roles", "")
if not roles_header:
return None
roles = {r.strip() for r in roles_header.split(",") if r.strip()}
admin_role = getattr(request.app.state, "oidc_role_admin", "admin")
operator_role = getattr(request.app.state, "oidc_role_operator", "operator")
member_role = getattr(request.app.state, "oidc_role_member", "member")
if admin_role in roles:
return "admin"
if operator_role in roles:
return "operator"
if member_role in roles:
return "member"
return None
def get_max_visibility_level(role: str | None) -> int:
"""Get the maximum visibility level for a given role.
Returns 0 for anonymous users (public only).
"""
if role is None:
return 0
return VISIBILITY_LEVELS.get(role, 0)
def get_visible_channel_indices(
session: DbSession,
max_level: int,
) -> set[int]:
"""Get set of visible channel_idx values based on visibility level.
Only returns indices for channels whose visibility level is at most
max_level (lower number = more permissive). The built-in Public
channel (idx 17) is always included.
"""
channels = session.execute(select(Channel)).scalars().all()
visible: set[int] = set()
for ch in channels:
level = VISIBILITY_LEVELS.get(ch.visibility, 0)
if level <= max_level:
idx = int(ch.channel_hash, 16)
visible.add(idx)
visible.add(17) # Built-in Public channel is always visible
return visible
def get_all_known_channel_indices(session: DbSession) -> set[int]:
"""Get set of all known channel_idx values from DB."""
channels = session.execute(select(Channel)).scalars().all()
return {int(ch.channel_hash, 16) for ch in channels}
+2
View File
@@ -11,6 +11,7 @@ from meshcore_hub.api.routes.telemetry import router as telemetry_router
from meshcore_hub.api.routes.dashboard import router as dashboard_router
from meshcore_hub.api.routes.user_profiles import router as user_profiles_router
from meshcore_hub.api.routes.adoptions import router as adoptions_router
from meshcore_hub.api.routes.channels import router as channels_router
api_router = APIRouter()
@@ -28,3 +29,4 @@ api_router.include_router(telemetry_router, prefix="/telemetry", tags=["Telemetr
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["Dashboard"])
api_router.include_router(user_profiles_router, prefix="/user", tags=["User"])
api_router.include_router(adoptions_router, prefix="/adoptions", tags=["Adoptions"])
api_router.include_router(channels_router, prefix="/channels", tags=["Channels"])
+158
View File
@@ -0,0 +1,158 @@
"""Channel API routes."""
from fastapi import APIRouter, HTTPException, Request
from sqlalchemy import select
from meshcore_hub.api.auth import RequireAdmin, RequireRead
from meshcore_hub.api.channel_visibility import (
VISIBILITY_LEVELS,
get_max_visibility_level,
resolve_user_role,
)
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.common.models.channel import Channel
from meshcore_hub.common.schemas.channels import (
ChannelCreate,
ChannelList,
ChannelRead,
ChannelUpdate,
)
router = APIRouter()
def _channel_to_read(channel: Channel, include_key: bool = False) -> ChannelRead:
"""Convert a Channel model to ChannelRead schema."""
return ChannelRead(
id=channel.id,
name=channel.name,
channel_hash=channel.channel_hash,
visibility=channel.visibility,
enabled=channel.enabled,
masked_key=channel.masked_key,
key_hex=channel.key_hex if include_key else None,
created_at=channel.created_at,
updated_at=channel.updated_at,
)
@router.get("", response_model=ChannelList)
async def list_channels(
_: RequireRead,
session: DbSession,
request: Request,
) -> ChannelList:
"""List channels, filtered by user role visibility.
When no OIDC roles are present (OIDC disabled or not logged in),
returns only public channels.
"""
role = resolve_user_role(request)
query = select(Channel).order_by(Channel.name)
channels = session.execute(query).scalars().all()
max_level = get_max_visibility_level(role)
filtered = []
for ch in channels:
level = VISIBILITY_LEVELS.get(ch.visibility, 0)
if level <= max_level:
filtered.append(_channel_to_read(ch, include_key=True))
return ChannelList(items=filtered, total=len(filtered))
@router.post("", response_model=ChannelRead, status_code=201)
async def create_channel(
__: RequireAdmin,
session: DbSession,
body: ChannelCreate,
) -> ChannelRead:
"""Create a new channel (admin only)."""
existing = session.execute(
select(Channel).where(Channel.name == body.name)
).scalar_one_or_none()
if existing:
raise HTTPException(
status_code=409, detail=f"Channel '{body.name}' already exists"
)
existing_key = session.execute(
select(Channel).where(Channel.key_hex == body.key_hex)
).scalar_one_or_none()
if existing_key:
raise HTTPException(
status_code=409, detail="Key already in use by another channel"
)
channel_hash = Channel.compute_channel_hash(body.key_hex)
channel = Channel(
name=body.name,
key_hex=body.key_hex,
channel_hash=channel_hash,
visibility=body.visibility,
enabled=body.enabled,
)
session.add(channel)
session.commit()
session.refresh(channel)
return _channel_to_read(channel, include_key=True)
@router.put("/{channel_id}", response_model=ChannelRead)
async def update_channel(
__: RequireAdmin,
session: DbSession,
channel_id: str,
body: ChannelUpdate,
) -> ChannelRead:
"""Update a channel (admin only, name is immutable)."""
channel = session.execute(
select(Channel).where(Channel.id == channel_id)
).scalar_one_or_none()
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
if body.key_hex is not None:
existing_key = session.execute(
select(Channel).where(
Channel.key_hex == body.key_hex, Channel.id != channel_id
)
).scalar_one_or_none()
if existing_key:
raise HTTPException(
status_code=409, detail="Key already in use by another channel"
)
channel.key_hex = body.key_hex
channel.channel_hash = Channel.compute_channel_hash(body.key_hex)
if body.visibility is not None:
channel.visibility = body.visibility
if body.enabled is not None:
channel.enabled = body.enabled
session.commit()
session.refresh(channel)
return _channel_to_read(channel, include_key=True)
@router.delete("/{channel_id}", status_code=204)
async def delete_channel(
__: RequireAdmin,
session: DbSession,
channel_id: str,
) -> None:
"""Delete a channel (admin only)."""
channel = session.execute(
select(Channel).where(Channel.id == channel_id)
).scalar_one_or_none()
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
session.delete(channel)
session.commit()
+42 -5
View File
@@ -2,11 +2,16 @@
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter
from fastapi import APIRouter, Request
from sqlalchemy import func, or_, select
from sqlalchemy.sql.elements import ColumnElement
from meshcore_hub.api.auth import RequireRead
from meshcore_hub.api.channel_visibility import (
get_max_visibility_level,
get_visible_channel_indices,
resolve_user_role,
)
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.common.models import (
Advertisement,
@@ -47,6 +52,7 @@ def _flood_only_filter(
async def get_stats(
_: RequireRead,
session: DbSession,
request: Request,
) -> DashboardStats:
"""Get dashboard statistics."""
now = datetime.now(timezone.utc)
@@ -54,6 +60,21 @@ async def get_stats(
yesterday = now - timedelta(days=1)
seven_days_ago = now - timedelta(days=7)
# Resolve channel visibility
role = resolve_user_role(request)
max_level = get_max_visibility_level(role)
visible_indices = get_visible_channel_indices(session, max_level)
# Build channel message visibility filter
def _channel_visible_filter(
model: type[Message] = Message,
) -> ColumnElement[bool]:
return or_(
model.message_type != "channel",
model.channel_idx.is_(None),
model.channel_idx.in_(visible_indices),
)
# Total nodes
total_nodes = session.execute(select(func.count()).select_from(Node)).scalar() or 0
@@ -67,7 +88,10 @@ async def get_stats(
# Total messages
total_messages = (
session.execute(select(func.count()).select_from(Message)).scalar() or 0
session.execute(
select(func.count()).select_from(Message).where(_channel_visible_filter())
).scalar()
or 0
)
# Messages today
@@ -76,6 +100,7 @@ async def get_stats(
select(func.count())
.select_from(Message)
.where(Message.received_at >= today_start)
.where(_channel_visible_filter())
).scalar()
or 0
)
@@ -118,6 +143,7 @@ async def get_stats(
select(func.count())
.select_from(Message)
.where(Message.received_at >= seven_days_ago)
.where(_channel_visible_filter())
).scalar()
or 0
)
@@ -171,11 +197,12 @@ async def get_stats(
for ad in recent_ads
]
# Channel message counts
# Channel message counts (only visible channels)
channel_counts_query = (
select(Message.channel_idx, func.count())
.where(Message.message_type == "channel")
.where(Message.channel_idx.isnot(None))
.where(Message.channel_idx.in_(visible_indices))
.group_by(Message.channel_idx)
)
channel_results = session.execute(channel_counts_query).all()
@@ -336,6 +363,7 @@ async def get_activity(
async def get_message_activity(
_: RequireRead,
session: DbSession,
request: Request,
days: int = 30,
) -> MessageActivity:
"""Get daily message activity for the specified period.
@@ -349,11 +377,13 @@ async def get_message_activity(
days = min(days, 90)
now = datetime.now(timezone.utc)
# End at start of today (exclude today's incomplete data)
end_date = now.replace(hour=0, minute=0, second=0, microsecond=0)
start_date = end_date - timedelta(days=days)
# Query message counts grouped by date
role = resolve_user_role(request)
max_level = get_max_visibility_level(role)
visible_indices = get_visible_channel_indices(session, max_level)
date_expr = func.date(Message.received_at)
query = (
@@ -363,6 +393,13 @@ async def get_message_activity(
)
.where(Message.received_at >= start_date)
.where(Message.received_at < end_date)
.where(
or_(
Message.message_type != "channel",
Message.channel_idx.is_(None),
Message.channel_idx.in_(visible_indices),
)
)
.group_by(date_expr)
.order_by(date_expr)
)
+29 -2
View File
@@ -3,11 +3,16 @@
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from sqlalchemy import func, select
from fastapi import APIRouter, HTTPException, Query, Request
from sqlalchemy import func, or_, select
from sqlalchemy.orm import aliased, selectinload
from meshcore_hub.api.auth import RequireRead
from meshcore_hub.api.channel_visibility import (
get_max_visibility_level,
get_visible_channel_indices,
resolve_user_role,
)
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.api.observer_utils import fetch_observers_for_events
from meshcore_hub.common.models import Message, Node, NodeTag
@@ -32,6 +37,7 @@ def _get_tag_name(node: Optional[Node]) -> Optional[str]:
async def list_messages(
_: RequireRead,
session: DbSession,
request: Request,
message_type: Optional[str] = Query(None, description="Filter by message type"),
pubkey_prefix: Optional[str] = Query(None, description="Filter by sender prefix"),
channel_idx: Optional[int] = Query(None, description="Filter by channel"),
@@ -79,6 +85,18 @@ async def list_messages(
if search:
query = query.where(Message.text.ilike(f"%{search}%"))
# Apply channel visibility filtering
role = resolve_user_role(request)
max_level = get_max_visibility_level(role)
visible_indices = get_visible_channel_indices(session, max_level)
query = query.where(
or_(
Message.message_type != "channel",
Message.channel_idx.is_(None),
Message.channel_idx.in_(visible_indices),
)
)
# Get total count
count_query = select(func.count()).select_from(query.subquery())
total = session.execute(count_query).scalar() or 0
@@ -211,6 +229,7 @@ async def list_messages(
async def get_message(
_: RequireRead,
session: DbSession,
request: Request,
message_id: str,
) -> MessageRead:
"""Get a single message by ID."""
@@ -227,6 +246,14 @@ async def get_message(
message, observer_pk = result
# Apply channel visibility filter
if message.message_type == "channel" and message.channel_idx is not None:
role = resolve_user_role(request)
max_level = get_max_visibility_level(role)
visible_indices = get_visible_channel_indices(session, max_level)
if message.channel_idx not in visible_indices:
raise HTTPException(status_code=404, detail="Message not found")
# Fetch observers for this message
observers = []
if message.event_hash:
+237 -4
View File
@@ -260,8 +260,7 @@ def _run_collector_service(
click.echo("")
builtin_keys = len(LetsMeshPacketDecoder.BUILTIN_CHANNEL_KEYS)
env_keys = len(settings.collector_channel_keys_list)
click.echo(f"Packet decoder: {builtin_keys} built-in keys, {env_keys} from .env")
click.echo(f"Packet decoder: {builtin_keys} built-in keys, loading from database")
click.echo("")
click.echo("Starting MQTT subscriber...")
@@ -281,8 +280,7 @@ def _run_collector_service(
cleanup_interval_hours=settings.data_retention_interval_hours,
node_cleanup_enabled=settings.node_cleanup_enabled,
node_cleanup_days=settings.node_cleanup_days,
channel_keys=settings.collector_channel_keys_list,
include_test_channel=settings.collector_include_test_channel,
channel_refresh_interval_seconds=settings.channel_refresh_interval_seconds,
)
@@ -309,6 +307,150 @@ def run_cmd(ctx: click.Context) -> None:
)
@collector.group("channel")
@click.pass_context
def channel_group(ctx: click.Context) -> None:
"""Manage decryption channels in the database."""
pass
@channel_group.command("list")
@click.pass_context
def channel_list_cmd(ctx: click.Context) -> None:
"""List all channels in the database."""
configure_logging(level=ctx.obj["log_level"])
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.models.channel import Channel
db = DatabaseManager(ctx.obj["database_url"])
with db.session_scope() as session:
channels = session.query(Channel).order_by(Channel.name).all()
if not channels:
click.echo("No channels found.")
else:
click.echo(
f"{'Name':<20} {'Key':<16} {'Hash':<6} {'Visibility':<12} {'Enabled'}"
)
click.echo("-" * 70)
for ch in channels:
click.echo(
f"{ch.name:<20} {ch.masked_key:<16} {ch.channel_hash:<6} "
f"{ch.visibility:<12} {'Yes' if ch.enabled else 'No'}"
)
db.dispose()
@channel_group.command("add")
@click.option("--name", required=True, help="Channel display name")
@click.option(
"--key", "key_hex", required=True, help="Channel key as hex (32 or 64 chars)"
)
@click.option(
"--visibility",
type=click.Choice(["public", "member", "operator", "admin"]),
default="public",
help="Channel visibility level (default: public)",
)
@click.pass_context
def channel_add_cmd(
ctx: click.Context,
name: str,
key_hex: str,
visibility: str,
) -> None:
"""Add a new channel to the database."""
configure_logging(level=ctx.obj["log_level"])
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.models.channel import Channel
db = DatabaseManager(ctx.obj["database_url"])
with db.session_scope() as session:
existing = session.query(Channel).filter(Channel.name == name).first()
if existing:
click.echo(f"Error: Channel '{name}' already exists.", err=True)
db.dispose()
return
channel = Channel(
name=name,
key_hex=key_hex.upper(),
channel_hash=Channel.compute_channel_hash(key_hex.upper()),
visibility=visibility,
enabled=True,
)
session.add(channel)
click.echo(f"Channel '{name}' added (hash={channel.channel_hash})")
db.dispose()
@channel_group.command("remove")
@click.option("--name", required=True, help="Channel name to remove")
@click.pass_context
def channel_remove_cmd(ctx: click.Context, name: str) -> None:
"""Remove a channel from the database."""
configure_logging(level=ctx.obj["log_level"])
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.models.channel import Channel
db = DatabaseManager(ctx.obj["database_url"])
with db.session_scope() as session:
channel = session.query(Channel).filter(Channel.name == name).first()
if not channel:
click.echo(f"Error: Channel '{name}' not found.", err=True)
db.dispose()
return
session.delete(channel)
click.echo(f"Channel '{name}' removed.")
db.dispose()
@channel_group.command("enable")
@click.option("--name", required=True, help="Channel name to enable")
@click.pass_context
def channel_enable_cmd(ctx: click.Context, name: str) -> None:
"""Enable a channel."""
configure_logging(level=ctx.obj["log_level"])
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.models.channel import Channel
db = DatabaseManager(ctx.obj["database_url"])
with db.session_scope() as session:
channel = session.query(Channel).filter(Channel.name == name).first()
if not channel:
click.echo(f"Error: Channel '{name}' not found.", err=True)
db.dispose()
return
channel.enabled = True
click.echo(f"Channel '{name}' enabled.")
db.dispose()
@channel_group.command("disable")
@click.option("--name", required=True, help="Channel name to disable")
@click.pass_context
def channel_disable_cmd(ctx: click.Context, name: str) -> None:
"""Disable a channel."""
configure_logging(level=ctx.obj["log_level"])
from meshcore_hub.common.database import DatabaseManager
from meshcore_hub.common.models.channel import Channel
db = DatabaseManager(ctx.obj["database_url"])
with db.session_scope() as session:
channel = session.query(Channel).filter(Channel.name == name).first()
if not channel:
click.echo(f"Error: Channel '{name}' not found.", err=True)
db.dispose()
return
channel.enabled = False
click.echo(f"Channel '{name}' disabled.")
db.dispose()
@collector.command("seed")
@click.option(
"--no-create-nodes",
@@ -408,9 +550,100 @@ def _run_seed_import(
elif verbose:
click.echo(f"\nNo node_tags.yaml found in {seed_home}")
# Import channels if file exists
channels_file = Path(seed_home) / "channels.yaml"
if channels_file.exists():
if verbose:
click.echo(f"\nImporting channels from: {channels_file}")
channel_stats = _import_channels(
file_path=str(channels_file),
db=db,
verbose=verbose,
)
if verbose:
click.echo(
f" Channels: {channel_stats['created']} created, "
f"{channel_stats['updated']} updated"
)
if channel_stats["errors"]:
for error in channel_stats["errors"]: # type: ignore[union-attr]
click.echo(f" Error: {error}", err=True)
imported_any = True
elif verbose:
click.echo(f"\nNo channels.yaml found in {seed_home}")
return imported_any
def _import_channels(
file_path: str,
db: "DatabaseManager",
verbose: bool = False,
) -> dict[str, int | list[str]]:
"""Import channels from a YAML file.
Supports two formats:
- Shorthand: name: HEX (value is string, treated as key_hex)
- Expanded: name: { key: HEX, enabled: true } (value is dict)
Visibility is always 'public' for seeded channels.
Returns:
Dict with 'created', 'updated', and 'errors' counts.
"""
import yaml
from meshcore_hub.common.models.channel import Channel
created: int = 0
updated: int = 0
errors: list[str] = []
with open(file_path) as f:
data = yaml.safe_load(f)
if not data or not isinstance(data, dict):
return {"created": created, "updated": updated, "errors": errors}
with db.session_scope() as session:
for name, value in data.items():
try:
if isinstance(value, str):
key_hex = value.strip().upper()
enabled = True
elif isinstance(value, dict):
key_hex = value.get("key", "").strip().upper()
enabled = value.get("enabled", True)
else:
errors.append(f"Invalid format for channel '{name}'")
continue
if not key_hex:
errors.append(f"Empty key for channel '{name}'")
continue
existing = session.query(Channel).filter(Channel.name == name).first()
if existing:
existing.key_hex = key_hex
existing.channel_hash = Channel.compute_channel_hash(key_hex)
existing.enabled = enabled
updated += 1
else:
channel = Channel(
name=name,
key_hex=key_hex,
channel_hash=Channel.compute_channel_hash(key_hex),
visibility="public",
enabled=enabled,
)
session.add(channel)
created += 1
except Exception as e:
errors.append(f"Channel '{name}': {e}")
return {"created": created, "updated": updated, "errors": errors}
@collector.command("import-tags")
@click.argument("file", type=click.Path(), required=False, default=None)
@click.option(
+43 -3
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import hashlib
import logging
import string
import threading
from typing import Any, NamedTuple
from meshcoredecoder import MeshCoreDecoder
@@ -41,6 +42,7 @@ class LetsMeshPacketDecoder:
self,
channel_keys: list[str] | None = None,
) -> None:
self._state_lock = threading.Lock()
self._channel_key_infos = self._normalize_channel_keys(channel_keys or [])
self._channel_keys = [info.key_hex for info in self._channel_key_infos]
self._channel_names_by_hash = {
@@ -67,6 +69,39 @@ class LetsMeshPacketDecoder:
key_store.add_channel_secrets(self._channel_keys)
return key_store
def reload_keys(self, channel_keys: list[str]) -> None:
"""Reload channel keys from a new key list (thread-safe).
Rebuilds the key store and channel name map without discarding the
decode cache. The state lock is held only during the atomic swap
of ``_key_store`` and ``_channel_names_by_hash``, not during key
normalization or KeyStore construction.
Args:
channel_keys: New list of channel key entries to load.
"""
new_infos = self._normalize_channel_keys(channel_keys)
new_keys = [info.key_hex for info in new_infos]
new_names = {info.channel_hash: info.label for info in new_infos if info.label}
new_store = MeshCoreKeyStore()
if new_keys:
new_store.add_channel_secrets(new_keys)
with self._state_lock:
self._channel_key_infos = new_infos
self._channel_keys = new_keys
self._channel_names_by_hash = new_names
self._key_store = new_store
logger.debug(
"LetsMesh decoder reloaded: %d channel keys (%s)",
len(new_infos),
", ".join(
f"{info.label or 'unlabeled'}=0x{info.channel_hash}"
for info in new_infos
),
)
@classmethod
def _normalize_channel_keys(cls, values: list[str]) -> list[ChannelKey]:
"""Normalize key list (labels + key + channel hash, deduplicated)."""
@@ -160,12 +195,15 @@ class LetsMeshPacketDecoder:
if not isinstance(channel_hash, str):
return None
return self._channel_names_by_hash.get(channel_hash.upper())
with self._state_lock:
return self._channel_names_by_hash.get(channel_hash.upper())
def channel_labels_by_index(self) -> dict[int, str]:
"""Return channel labels keyed by numeric channel index (0-255)."""
labels: dict[int, str] = {}
for info in self._channel_key_infos:
with self._state_lock:
infos = list(self._channel_key_infos)
for info in infos:
if not info.label:
continue
@@ -202,8 +240,10 @@ class LetsMeshPacketDecoder:
def _decode_raw(self, raw_hex: str) -> dict[str, Any] | None:
"""Decode raw packet hex with native Python decoder (cached per packet hex)."""
try:
with self._state_lock:
key_store = self._key_store
options = DecryptionOptions(
key_store=self._key_store,
key_store=key_store,
attempt_decryption=True,
)
result = MeshCoreDecoder.decode(raw_hex, options)
+116 -18
View File
@@ -47,8 +47,7 @@ class Subscriber(LetsMeshNormalizer):
cleanup_interval_hours: int = 24,
node_cleanup_enabled: bool = False,
node_cleanup_days: int = 90,
channel_keys: list[str] | None = None,
include_test_channel: bool = False,
channel_refresh_interval_seconds: int = 300,
):
"""Initialize subscriber.
@@ -61,8 +60,7 @@ class Subscriber(LetsMeshNormalizer):
cleanup_interval_hours: Hours between cleanup runs
node_cleanup_enabled: Enable automatic cleanup of inactive nodes
node_cleanup_days: Remove nodes not seen for this many days
channel_keys: Optional channel keys for decrypting group text
include_test_channel: Include built-in test channel messages
channel_refresh_interval_seconds: Seconds between channel key refresh
"""
self.mqtt = mqtt_client
self.db = db_manager
@@ -85,10 +83,14 @@ class Subscriber(LetsMeshNormalizer):
self._node_cleanup_days = node_cleanup_days
self._cleanup_thread: Optional[threading.Thread] = None
self._last_cleanup: Optional[datetime] = None
# Channel key refresh
self._channel_refresh_interval_seconds = channel_refresh_interval_seconds
self._channel_refresh_thread: Optional[threading.Thread] = None
# Load initial channel keys from database
self._include_test_channel = self._load_channel_keys_from_db()
self._letsmesh_decoder = LetsMeshPacketDecoder(
channel_keys=channel_keys,
channel_keys=self._db_channel_keys,
)
self._include_test_channel = include_test_channel
@property
def is_healthy(self) -> bool:
@@ -99,6 +101,68 @@ class Subscriber(LetsMeshNormalizer):
"""
return self._running and self._mqtt_connected and self._db_connected
def _load_channel_keys_from_db(self) -> bool:
"""Load channel keys from the database (synchronous).
Queries enabled channels, merges with built-in keys, and
determines whether the test channel should be included.
Returns:
True if test channel should be included (DB row exists with enabled=True).
"""
self._db_channel_keys: list[str] = []
include_test = False
try:
from meshcore_hub.common.models.channel import Channel
with self.db.session_scope() as session:
channels = (
session.query(Channel)
.filter(Channel.enabled == True) # noqa: E712
.all()
)
for ch in channels:
self._db_channel_keys.append(f"{ch.name}={ch.key_hex}")
if ch.name.lower() == "test":
include_test = True
logger.info(
"Loaded %d channel keys from database (include_test=%s)",
len(self._db_channel_keys),
include_test,
)
except Exception as e:
logger.warning("Failed to load channel keys from database: %s", e)
self._db_channel_keys = []
return include_test
def _refresh_channel_keys_from_db(self) -> None:
"""Refresh channel keys from the database and reload the decoder."""
new_keys: list[str] = []
include_test = False
try:
from meshcore_hub.common.models.channel import Channel
with self.db.session_scope() as session:
channels = (
session.query(Channel)
.filter(Channel.enabled == True) # noqa: E712
.all()
)
for ch in channels:
new_keys.append(f"{ch.name}={ch.key_hex}")
if ch.name.lower() == "test":
include_test = True
self._db_channel_keys = new_keys
self._include_test_channel = include_test
self._letsmesh_decoder.reload_keys(new_keys)
logger.info(
"Refreshed %d channel keys from database (include_test=%s)",
len(new_keys),
include_test,
)
except Exception as e:
logger.error("Failed to refresh channel keys from database: %s", e)
def get_health_status(self) -> dict[str, Any]:
"""Get detailed health status.
@@ -364,6 +428,40 @@ class Subscriber(LetsMeshNormalizer):
if self._cleanup_thread.is_alive():
logger.warning("Cleanup scheduler thread did not stop cleanly")
def _start_channel_refresh_scheduler(self) -> None:
"""Start background thread for periodic channel key refresh."""
interval = self._channel_refresh_interval_seconds
if interval <= 0:
logger.info("Channel key refresh is disabled (interval=0)")
return
logger.info("Starting channel refresh scheduler (interval=%ds)", interval)
def run_refresh_loop() -> None:
"""Periodically refresh channel keys from database."""
while self._running:
for _ in range(interval):
if not self._running:
break
time.sleep(1)
if self._running:
try:
self._refresh_channel_keys_from_db()
except Exception as e:
logger.error("Channel refresh error: %s", e, exc_info=True)
self._channel_refresh_thread = threading.Thread(
target=run_refresh_loop, daemon=True, name="channel-refresh"
)
self._channel_refresh_thread.start()
def _stop_channel_refresh_scheduler(self) -> None:
"""Stop the channel refresh scheduler thread."""
if self._channel_refresh_thread and self._channel_refresh_thread.is_alive():
self._channel_refresh_thread.join(timeout=5.0)
if self._channel_refresh_thread.is_alive():
logger.warning("Channel refresh thread did not stop cleanly")
def start(self) -> None:
"""Start the subscriber."""
logger.info("Starting collector subscriber")
@@ -428,6 +526,9 @@ class Subscriber(LetsMeshNormalizer):
# Start cleanup scheduler if configured
self._start_cleanup_scheduler()
# Start channel key refresh scheduler
self._start_channel_refresh_scheduler()
# Start health reporter for Docker health checks
self._health_reporter = HealthReporter(
component="collector",
@@ -463,6 +564,9 @@ class Subscriber(LetsMeshNormalizer):
# Stop cleanup scheduler
self._stop_cleanup_scheduler()
# Stop channel refresh scheduler
self._stop_channel_refresh_scheduler()
# Stop webhook processor
self._stop_webhook_processor()
@@ -495,8 +599,7 @@ def create_subscriber(
cleanup_interval_hours: int = 24,
node_cleanup_enabled: bool = False,
node_cleanup_days: int = 90,
channel_keys: list[str] | None = None,
include_test_channel: bool = False,
channel_refresh_interval_seconds: int = 300,
) -> Subscriber:
"""Create a configured subscriber instance.
@@ -516,8 +619,7 @@ def create_subscriber(
cleanup_interval_hours: Hours between cleanup runs
node_cleanup_enabled: Enable automatic cleanup of inactive nodes
node_cleanup_days: Remove nodes not seen for this many days
channel_keys: Optional channel keys for decrypting group text
include_test_channel: Include built-in test channel messages
channel_refresh_interval_seconds: Seconds between channel key refresh
Returns:
Configured Subscriber instance
@@ -550,8 +652,7 @@ def create_subscriber(
cleanup_interval_hours=cleanup_interval_hours,
node_cleanup_enabled=node_cleanup_enabled,
node_cleanup_days=node_cleanup_days,
channel_keys=channel_keys,
include_test_channel=include_test_channel,
channel_refresh_interval_seconds=channel_refresh_interval_seconds,
)
# Register handlers
@@ -578,8 +679,7 @@ def run_collector(
cleanup_interval_hours: int = 24,
node_cleanup_enabled: bool = False,
node_cleanup_days: int = 90,
channel_keys: list[str] | None = None,
include_test_channel: bool = False,
channel_refresh_interval_seconds: int = 300,
) -> None:
"""Run the collector (blocking).
@@ -599,8 +699,7 @@ def run_collector(
cleanup_interval_hours: Hours between cleanup runs
node_cleanup_enabled: Enable automatic cleanup of inactive nodes
node_cleanup_days: Remove nodes not seen for this many days
channel_keys: Optional channel keys for decrypting group text
include_test_channel: Include built-in test channel messages
channel_refresh_interval_seconds: Seconds between channel key refresh
"""
subscriber = create_subscriber(
mqtt_host=mqtt_host,
@@ -618,8 +717,7 @@ def run_collector(
cleanup_interval_hours=cleanup_interval_hours,
node_cleanup_enabled=node_cleanup_enabled,
node_cleanup_days=node_cleanup_days,
channel_keys=channel_keys,
include_test_channel=include_test_channel,
channel_refresh_interval_seconds=channel_refresh_interval_seconds,
)
# Set up signal handlers
+13 -20
View File
@@ -1,7 +1,6 @@
"""Pydantic Settings for MeshCore Hub configuration."""
from enum import Enum
import re
from typing import Optional
from pydantic import Field, field_validator
@@ -138,16 +137,10 @@ class CollectorSettings(CommonSettings):
description="Remove nodes not seen for this many days (last_seen)",
ge=1,
)
collector_channel_keys: Optional[str] = Field(
default=None,
description=(
"Optional channel secret keys for message decryption. "
"Provide as comma/space separated hex values."
),
)
collector_include_test_channel: bool = Field(
default=False,
description="Include built-in 'test' channel messages (channel_idx 217).",
channel_refresh_interval_seconds: int = Field(
default=300,
description="Seconds between channel key refresh from database",
ge=10,
)
@property
@@ -182,15 +175,11 @@ class CollectorSettings(CommonSettings):
return str(Path(self.effective_seed_home) / "node_tags.yaml")
@property
def collector_channel_keys_list(self) -> list[str]:
"""Parse configured channel keys into a normalized list."""
if not self.collector_channel_keys:
return []
return [
part.strip()
for part in re.split(r"[,\s]+", self.collector_channel_keys)
if part.strip()
]
def channels_file(self) -> str:
"""Get the path to channels.yaml in seed_home."""
from pathlib import Path
return str(Path(self.effective_seed_home) / "channels.yaml")
@field_validator("database_url")
@classmethod
@@ -383,6 +372,9 @@ class WebSettings(CommonSettings):
default=True, description="Enable the /map page and /map/data endpoint"
)
feature_members: bool = Field(default=True, description="Enable the /members page")
feature_channels: bool = Field(
default=True, description="Enable the /channels page"
)
feature_pages: bool = Field(
default=True, description="Enable custom markdown pages"
)
@@ -412,6 +404,7 @@ class WebSettings(CommonSettings):
"messages": self.feature_messages,
"map": self.feature_map and self.feature_nodes,
"members": self.feature_members and self.oidc_enabled,
"channels": self.feature_channels,
"pages": self.feature_pages,
}
@@ -11,6 +11,7 @@ from meshcore_hub.common.models.event_log import EventLog
from meshcore_hub.common.models.user_profile import UserProfile
from meshcore_hub.common.models.user_profile_node import UserProfileNode
from meshcore_hub.common.models.event_observer import EventObserver, add_event_observer
from meshcore_hub.common.models.channel import Channel, ChannelVisibility
__all__ = [
"Base",
@@ -26,4 +27,6 @@ __all__ = [
"UserProfileNode",
"EventObserver",
"add_event_observer",
"Channel",
"ChannelVisibility",
]
+60
View File
@@ -0,0 +1,60 @@
"""Channel model for database-backed decrypt keys."""
import hashlib
from enum import Enum
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from meshcore_hub.common.models.base import Base, TimestampMixin, UUIDMixin
class ChannelVisibility(str, Enum):
"""Channel visibility/permission levels."""
PUBLIC = "public"
MEMBER = "member"
OPERATOR = "operator"
ADMIN = "admin"
class Channel(Base, UUIDMixin, TimestampMixin):
"""Channel model for database-backed decrypt keys with permission-based visibility.
Attributes:
id: UUID primary key
name: Channel display name (unique, non-empty)
key_hex: Secret key as uppercase hex (supports AES-128 and AES-256)
channel_hash: First byte of SHA-256 of key_hex (2-char uppercase hex)
visibility: Permission level (public, member, operator, admin)
enabled: Whether the channel is active
created_at: Record creation timestamp
updated_at: Record update timestamp
"""
__tablename__ = "channels"
name: Mapped[str] = mapped_column(
String(100), unique=True, nullable=False, index=True
)
key_hex: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
channel_hash: Mapped[str] = mapped_column(String(2), nullable=False)
visibility: Mapped[str] = mapped_column(
String(20), default=ChannelVisibility.PUBLIC.value, nullable=False
)
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
def __repr__(self) -> str:
return f"<Channel(name={self.name}, hash={self.channel_hash}, visibility={self.visibility})>"
@staticmethod
def compute_channel_hash(key_hex: str) -> str:
"""Compute channel hash (first byte of SHA-256 of key_hex)."""
return hashlib.sha256(bytes.fromhex(key_hex)).digest()[:1].hex().upper()
@property
def masked_key(self) -> str:
"""Return masked key showing first/last 4 chars."""
if len(self.key_hex) <= 8:
return self.key_hex
return f"{self.key_hex[:4]}...{self.key_hex[-4:]}"
+101
View File
@@ -0,0 +1,101 @@
"""Pydantic schemas for channel API endpoints."""
import re
from datetime import datetime
from typing import Literal, Optional
from pydantic import BaseModel, Field, field_validator
class ChannelCreate(BaseModel):
"""Schema for creating a channel."""
name: str = Field(
...,
min_length=1,
max_length=100,
description="Channel display name",
)
key_hex: str = Field(
...,
min_length=32,
max_length=64,
description="Channel secret key as uppercase hex (32 or 64 chars)",
)
visibility: Literal["public", "member", "operator", "admin"] = Field(
default="public",
description="Channel visibility/permission level",
)
enabled: bool = Field(
default=True,
description="Whether the channel is active",
)
@field_validator("key_hex")
@classmethod
def validate_key_hex(cls, v: str) -> str:
"""Validate key is uppercase hex and correct length."""
v = v.strip().upper()
if not re.fullmatch(r"[0-9A-F]+", v):
raise ValueError("key_hex must contain only hexadecimal characters")
if len(v) not in (32, 64):
raise ValueError("key_hex must be 32 or 64 hex characters")
return v
class ChannelUpdate(BaseModel):
"""Schema for updating a channel."""
key_hex: Optional[str] = Field(
default=None,
min_length=32,
max_length=64,
description="Channel secret key as uppercase hex",
)
visibility: Optional[Literal["public", "member", "operator", "admin"]] = Field(
default=None,
description="Channel visibility/permission level",
)
enabled: Optional[bool] = Field(
default=None,
description="Whether the channel is active",
)
@field_validator("key_hex")
@classmethod
def validate_key_hex(cls, v: str | None) -> str | None:
"""Validate key is uppercase hex and correct length."""
if v is None:
return v
v = v.strip().upper()
if not re.fullmatch(r"[0-9A-F]+", v):
raise ValueError("key_hex must contain only hexadecimal characters")
if len(v) not in (32, 64):
raise ValueError("key_hex must be 32 or 64 hex characters")
return v
class ChannelRead(BaseModel):
"""Schema for reading a channel."""
id: str = Field(..., description="Channel UUID")
name: str = Field(..., description="Channel display name")
channel_hash: str = Field(..., description="Channel hash (2-char hex)")
visibility: str = Field(..., description="Visibility level")
enabled: bool = Field(..., description="Whether the channel is active")
masked_key: str = Field(..., description="Masked key (first/last 4 chars)")
key_hex: Optional[str] = Field(
default=None,
description="Full key hex (visible to users with channel access)",
)
created_at: datetime = Field(..., description="Creation timestamp")
updated_at: datetime = Field(..., description="Last update timestamp")
model_config = {"from_attributes": True}
class ChannelList(BaseModel):
"""Schema for paginated channel list response."""
items: list[ChannelRead] = Field(..., description="List of channels")
total: int = Field(..., description="Total number of channels")
+40 -21
View File
@@ -2,8 +2,6 @@
import json
import logging
import os
import re
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
@@ -124,6 +122,15 @@ def _build_endpoint_access(
"GET": _OPEN,
"PUT": _AUTHENTICATED,
},
"v1/channels": {
"GET": _OPEN,
"POST": frozenset({role_admin}),
},
"v1/channels/": {
"POST": frozenset({role_admin}),
"PUT": frozenset({role_admin}),
"DELETE": frozenset({role_admin}),
},
}
@@ -161,27 +168,31 @@ def check_api_access(
return False
def _parse_decoder_key_entries(raw: str | None) -> list[str]:
"""Parse COLLECTOR_CHANNEL_KEYS into key entries."""
if not raw:
return []
return [part.strip() for part in re.split(r"[,\s]+", raw) if part.strip()]
def _build_channel_labels() -> dict[str, str]:
"""Build UI channel labels from built-in + configured decoder keys."""
raw_keys = os.getenv("COLLECTOR_CHANNEL_KEYS")
include_test = os.getenv("COLLECTOR_INCLUDE_TEST_CHANNEL", "false").lower() in (
"true",
"1",
"yes",
)
decoder = LetsMeshPacketDecoder(
channel_keys=_parse_decoder_key_entries(raw_keys),
)
"""Build UI channel labels from built-in + database channel keys."""
decoder = LetsMeshPacketDecoder(channel_keys=[])
labels = decoder.channel_labels_by_index()
if not include_test:
labels.pop(LetsMeshPacketDecoder.TEST_CHANNEL_IDX, None)
try:
from meshcore_hub.common.config import get_collector_settings
settings = get_collector_settings()
from meshcore_hub.common.database import DatabaseManager
db = DatabaseManager(settings.effective_database_url)
from meshcore_hub.common.models.channel import Channel
with db.session_scope() as session:
channels = session.query(Channel).filter(Channel.enabled.is_(True)).all()
db_decoder = LetsMeshPacketDecoder(
channel_keys=[f"{ch.name}={ch.key_hex}" for ch in channels]
)
db_labels = db_decoder.channel_labels_by_index()
labels.update(db_labels)
db.dispose()
except Exception as e:
logger.warning("Failed to load channel labels from database: %s", e)
return {str(idx): label for idx, label in sorted(labels.items())}
@@ -683,6 +694,13 @@ def create_app(
):
resp_headers[k] = v
if (
response.status_code < 300
and path.startswith("v1/channels")
and request.method in ("POST", "PUT", "DELETE")
):
request.app.state.channel_labels = _build_channel_labels()
return Response(
content=response.content,
status_code=response.status_code,
@@ -933,6 +951,7 @@ def create_app(
("/dashboard", "hourly", "0.9", "dashboard"),
("/nodes", "hourly", "0.9", "nodes"),
("/advertisements", "hourly", "0.8", "advertisements"),
("/channels", "daily", "0.7", "channels"),
("/map", "daily", "0.7", "map"),
("/members", "weekly", "0.6", "members"),
]
+2
View File
@@ -23,6 +23,7 @@
--color-nodes: oklch(0.65 0.24 265); /* violet */
--color-adverts: oklch(0.7 0.17 330); /* magenta */
--color-messages: oklch(0.75 0.18 180); /* teal */
--color-channels: oklch(0.72 0.15 300); /* purple */
--color-map: oklch(0.8471 0.199 83.87); /* yellow (matches btn-warning) */
--color-members: oklch(0.72 0.17 50); /* orange */
--color-neutral: oklch(0.3 0.01 250); /* subtle dark grey */
@@ -35,6 +36,7 @@
--color-nodes: oklch(0.50 0.24 265);
--color-adverts: oklch(0.55 0.17 330);
--color-messages: oklch(0.55 0.18 180);
--color-channels: oklch(0.55 0.15 300);
--color-map: oklch(0.58 0.16 45);
--color-members: oklch(0.55 0.18 25);
--color-neutral: oklch(0.85 0.01 250);
+9 -1
View File
@@ -8,7 +8,7 @@
import { Router } from './router.js';
import { html, litRender, getConfig, hasRole, renderAuthSection } from './components.js';
import { loadLocale, t } from './i18n.js';
import { iconHome, iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMap, iconMembers, iconPage } from './icons.js';
import { iconHome, iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMap, iconMembers, iconPage, iconChannel } from './icons.js';
// Page modules (lazy-loaded)
const pages = {
@@ -20,6 +20,7 @@ const pages = {
advertisements: () => import('./pages/advertisements.js'),
map: () => import('./pages/map.js'),
members: () => import('./pages/members.js'),
channels: () => import('./pages/channels.js'),
customPage: () => import('./pages/custom-page.js'),
notFound: () => import('./pages/not-found.js'),
profile: () => import('./pages/profile.js'),
@@ -73,6 +74,9 @@ if (features.nodes !== false) {
if (features.messages !== false) {
router.addRoute('/messages', pageHandler(pages.messages));
}
if (features.channels !== false) {
router.addRoute('/channels', pageHandler(pages.channels));
}
if (features.advertisements !== false) {
router.addRoute('/advertisements', pageHandler(pages.advertisements));
}
@@ -150,6 +154,7 @@ function updatePageTitle(pathname) {
if (features.dashboard !== false) titles['/dashboard'] = composePageTitle('entities.dashboard');
if (features.nodes !== false) titles['/nodes'] = composePageTitle('entities.nodes');
if (features.messages !== false) titles['/messages'] = composePageTitle('entities.messages');
if (features.channels !== false) titles['/channels'] = composePageTitle('entities.channels');
if (features.advertisements !== false) titles['/advertisements'] = composePageTitle('entities.advertisements');
if (features.map !== false) titles['/map'] = composePageTitle('entities.map');
if (features.members !== false) titles['/members'] = composePageTitle('entities.members');
@@ -201,6 +206,9 @@ function renderMobileNav(config) {
if (features.messages !== false) {
items.push(html`<li><a href="/messages" data-nav-link>${iconMessages('h-5 w-5 nav-icon-messages')} ${t('entities.messages')}</a></li>`);
}
if (features.channels !== false) {
items.push(html`<li><a href="/channels" data-nav-link>${iconChannel('h-5 w-5')} ${t('entities.channels')}</a></li>`);
}
if (features.map !== false) {
items.push(html`<li><a href="/map" data-nav-link>${iconMap('h-5 w-5 nav-icon-map')} ${t('entities.map')}</a></li>`);
}
@@ -0,0 +1,263 @@
import { apiGet, apiPost, apiPut, apiDelete } from '../api.js';
import { html, litRender, nothing, t, errorAlert, getConfig, hasRole } from '../components.js';
import { iconChannel, iconPlus, iconEdit, iconTrash, iconLock } from '../icons.js';
const VISIBILITY_COLORS = {
public: 'badge-success',
member: 'badge-warning',
operator: 'badge-orange',
admin: 'badge-error',
};
function renderVisibilityBadge(visibility, oidcEnabled) {
if (!oidcEnabled) return nothing;
const colorClass = VISIBILITY_COLORS[visibility] || 'badge-ghost';
return html`<span class="badge ${colorClass} badge-sm">${visibility}</span>`;
}
function renderChannelCard(channel, { oidcEnabled, isAdmin, onDelete, onEdit, onNavigate }) {
const visibilityBadge = renderVisibilityBadge(channel.visibility, oidcEnabled);
const enabledBadge = !channel.enabled
? html`<span class="badge badge-ghost badge-sm">${t('channels.disabled')}</span>`
: nothing;
const qrId = `qr-${channel.id}`;
const channelIdx = parseInt(channel.channel_hash, 16);
const adminButtons = isAdmin
? html`<div class="flex gap-2 mt-2">
<button class="btn btn-xs btn-outline" @click=${(e) => { e.stopPropagation(); onEdit(channel); }}>
${iconEdit('h-3 w-3')} ${t('common.edit')}
</button>
<button class="btn btn-xs btn-outline btn-error" @click=${(e) => { e.stopPropagation(); onDelete(channel); }}>
${iconTrash('h-3 w-3')} ${t('common.delete')}
</button>
</div>`
: nothing;
const keyDisplay = channel.key_hex
? html`<div class="font-mono text-xs opacity-70 mt-1 break-all select-all">${channel.key_hex.toLowerCase()}</div>`
: nothing;
return html`<div class="card bg-base-100 shadow-xl cursor-pointer" @click=${() => onNavigate(channelIdx)}>
<div class="card-body">
<h2 class="card-title flex items-center gap-2">
${iconChannel('h-5 w-5')}
${channel.name}
${visibilityBadge}
${enabledBadge}
</h2>
${keyDisplay}
<div id="${qrId}" class="qr-container mt-2"></div>
${adminButtons}
</div>
</div>`;
}
function renderAddButton(onAdd) {
return html`<button class="btn btn-primary btn-sm" @click=${onAdd}>
${iconPlus('h-4 w-4')} ${t('channels.add_channel')}
</button>`;
}
function renderChannelModal({ channel, isEdit, onSave, onCancel }) {
const title = isEdit ? t('channels.edit_channel') : t('channels.add_channel');
return html`<dialog open class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">${title}</h3>
<form @submit=${(e) => { e.preventDefault(); onSave(); }}>
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-3 items-center mb-4">
<label class="label-text text-right">${t('channels.name_label')}</label>
<input type="text" id="channel-modal-name" class="input input-bordered input-sm"
.value=${isEdit ? channel.name : ''}
?disabled=${isEdit}
placeholder="${t('channels.name_label')}"
required maxlength="100" />
${!isEdit ? html`
<label class="label-text text-right">${t('channels.key_label')}</label>
<input type="text" id="channel-modal-key" class="input input-bordered input-sm font-mono"
placeholder="e.g. ABCDEF0123456789..."
required minlength="32" maxlength="64"
pattern="[0-9A-Fa-f]{32,64}" />` : nothing}
<label class="label-text text-right">${t('channels.visibility_label')}</label>
<select id="channel-modal-visibility" class="select select-bordered select-sm">
<option value="public" .selected=${channel?.visibility === 'public' || !channel}>public</option>
<option value="member" .selected=${channel?.visibility === 'member'}>member</option>
<option value="operator" .selected=${channel?.visibility === 'operator'}>operator</option>
<option value="admin" .selected=${channel?.visibility === 'admin'}>admin</option>
</select>
<div></div>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" id="channel-modal-enabled" class="checkbox checkbox-sm"
.checked=${channel?.enabled !== false} />
<span class="label-text">${t('channels.enabled_label')}</span>
</label>
</div>
<div class="modal-action">
<button type="button" class="btn btn-ghost" @click=${onCancel}>${t('common.cancel')}</button>
<button type="submit" class="btn btn-primary">${t('common.save')}</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop"><button @click=${onCancel}></button></form>
</dialog>`;
}
function renderDeleteModal({ channel, onConfirm, onCancel }) {
return html`<dialog open class="modal modal-open">
<div class="modal-box">
<h3 class="font-bold text-lg mb-4">${t('channels.delete_channel')}</h3>
<p>${t('channels.delete_confirm', { name: channel.name })}</p>
<div class="modal-action">
<button class="btn btn-ghost" @click=${onCancel}>${t('common.cancel')}</button>
<button class="btn btn-error" @click=${onConfirm}>${t('common.delete')}</button>
</div>
</div>
<form method="dialog" class="modal-backdrop"><button @click=${onCancel}></button></form>
</dialog>`;
}
export async function render(container, params, router) {
try {
const config = getConfig();
const oidcEnabled = config.oidc_enabled;
const isAdmin = hasRole('admin');
const data = await apiGet('/api/v1/channels');
const channels = data.items || [];
let modalState = null;
async function refresh() {
const newData = await apiGet('/api/v1/channels');
renderPage(newData.items || []);
}
function renderPage(channelsList) {
const adminHeader = isAdmin
? html`<div class="flex justify-end mb-4">${renderAddButton(handleAdd)}</div>`
: nothing;
const emptyMessage = channelsList.length === 0
? html`<div class="text-center py-10 opacity-60">
${t('common.no_entity_found', { entity: t('entities.channels').toLowerCase() })}
</div>`
: nothing;
let modalHtml = nothing;
if (modalState?.type === 'add' || modalState?.type === 'edit') {
modalHtml = renderChannelModal({
channel: modalState.channel,
isEdit: modalState.type === 'edit',
onSave: handleSave,
onCancel: () => { modalState = null; renderPage(channelsList); },
});
} else if (modalState?.type === 'delete') {
modalHtml = renderDeleteModal({
channel: modalState.channel,
onConfirm: handleDeleteConfirm,
onCancel: () => { modalState = null; renderPage(channelsList); },
});
}
litRender(html`
<div class="mb-4">
<h1 class="text-2xl font-bold flex items-center gap-2">
${iconChannel('h-7 w-7')}
${t('channels.title')}
</h1>
</div>
${adminHeader}
${emptyMessage}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
${channelsList.map(ch => renderChannelCard(ch, {
oidcEnabled,
isAdmin,
onDelete: handleDeleteClick,
onEdit: handleEditClick,
onNavigate: (idx) => router.navigate(`/messages?channel_idx=${idx}`),
}))}
</div>
${modalHtml}
`, container);
channelsList.forEach(ch => {
const qrEl = document.getElementById(`qr-${ch.id}`);
if (qrEl && !qrEl.hasChildNodes() && ch.key_hex) {
const qrUrl = `meshcore://channel/add?name=${encodeURIComponent(ch.name)}&secret=${ch.key_hex.toLowerCase()}`;
new QRCode(qrEl, {
text: qrUrl,
width: 128,
height: 128,
correctLevel: QRCode.CorrectLevel.M,
});
}
});
}
function handleAdd() {
modalState = { type: 'add', channel: { visibility: 'public', enabled: true } };
renderPage(channels);
}
function handleEditClick(channel) {
modalState = { type: 'edit', channel };
renderPage(channels);
}
function handleDeleteClick(channel) {
modalState = { type: 'delete', channel };
renderPage(channels);
}
async function handleSave() {
const nameEl = document.getElementById('channel-modal-name');
const keyEl = document.getElementById('channel-modal-key');
const visEl = document.getElementById('channel-modal-visibility');
const enabledEl = document.getElementById('channel-modal-enabled');
const isEdit = modalState.type === 'edit';
const body = {
visibility: visEl.value,
enabled: enabledEl.checked,
};
if (!isEdit) {
body.name = nameEl.value.trim();
body.key_hex = keyEl.value.trim().toUpperCase();
} else {
if (keyEl && keyEl.value) {
body.key_hex = keyEl.value.trim().toUpperCase();
}
}
try {
if (isEdit) {
await apiPut(`/api/v1/channels/${modalState.channel.id}`, body);
} else {
await apiPost('/api/v1/channels', body);
}
modalState = null;
await refresh();
} catch (e) {
alert(e.message || 'Failed to save channel');
}
}
async function handleDeleteConfirm() {
try {
await apiDelete(`/api/v1/channels/${modalState.channel.id}`);
modalState = null;
await refresh();
} catch (e) {
alert(e.message || 'Failed to delete channel');
}
}
renderPage(channels);
} catch (e) {
litRender(errorAlert(e.message || t('common.failed_to_load_page')), container);
}
}
@@ -160,17 +160,24 @@ function renderChartCards({ showNodes, showAdverts, showMessages }) {
export async function render(container, params, router) {
try {
const config = getConfig();
const channelLabels = getChannelLabelsMap(config);
let channelLabels = new Map();
const features = config.features || {};
const showNodes = features.nodes !== false;
const showAdverts = features.advertisements !== false;
const showMessages = features.messages !== false;
const [stats, advertActivity, messageActivity, nodeCount] = await Promise.all([
const [stats, advertActivity, messageActivity, nodeCount, channelsData] = await Promise.all([
apiGet('/api/v1/dashboard/stats'),
apiGet('/api/v1/dashboard/activity', { days: 7 }),
apiGet('/api/v1/dashboard/message-activity', { days: 7 }),
apiGet('/api/v1/dashboard/node-count', { days: 7 }),
apiGet('/api/v1/channels'),
]);
channelLabels = new Map([
...getChannelLabelsMap(config),
...(channelsData.items || [])
.map(ch => [parseInt(ch.channel_hash, 16), ch.name])
.filter(([idx]) => Number.isInteger(idx)),
]);
// Top section: stats + charts
@@ -5,7 +5,7 @@ import {
} from '../components.js';
import {
iconDashboard, iconNodes, iconAdvertisements, iconMessages, iconMembers, iconMap,
iconPage, iconInfo, iconChart, iconAntenna, iconUsers,
iconPage, iconInfo, iconChart, iconAntenna, iconUsers, iconChannel,
iconSettings, iconFrequency, iconBandwidth, iconSpreadingFactor, iconCodingRate, iconTxPower,
} from '../icons.js';
@@ -99,6 +99,12 @@ function renderHeroSection({ networkName, logoUrl, logoInvertLight, networkCity,
label: t('entities.messages'),
colorVar: '--color-messages',
}) : nothing}
${features.channels !== false ? renderNavCard({
href: '/channels',
icon: iconChannel('w-full h-full'),
label: t('entities.channels'),
colorVar: '--color-channels',
}) : nothing}
${features.members !== false ? renderNavCard({
href: '/members',
icon: iconMembers('w-full h-full'),
@@ -24,7 +24,7 @@ export async function render(container, params, router) {
const order = query.order || 'desc';
const config = getConfig();
const channelLabels = getChannelLabelsMap(config);
let channelLabels = new Map();
const tz = config.timezone || '';
const tzBadge = tz && tz !== 'UTC' ? html`<span class="text-sm opacity-60">${tz}</span>` : nothing;
const navigate = (url) => router.navigate(url);
@@ -206,9 +206,16 @@ ${displayContent}`, container);
try {
const apiParams = { limit, offset, message_type, channel_idx, sort, order };
if (observed_by.length > 0) apiParams.observed_by = observed_by;
const [data, nodesData] = await Promise.all([
const [data, nodesData, channelsData] = await Promise.all([
apiGet('/api/v1/messages', apiParams),
apiGet('/api/v1/nodes', { limit: 500, observer: true }),
apiGet('/api/v1/channels'),
]);
channelLabels = new Map([
...getChannelLabelsMap(config),
...(channelsData.items || [])
.map(ch => [parseInt(ch.channel_hash, 16), ch.name])
.filter(([idx]) => Number.isInteger(idx)),
]);
const messages = dedupeBySignature(data.items || []);
const allNodes = nodesData.items || [];
+15 -1
View File
@@ -14,7 +14,8 @@
"member": "Member",
"tags": "Tags",
"tag": "Tag",
"channel": "Channel"
"channel": "Channel",
"channels": "Channels"
},
"common": {
"filter": "Filter",
@@ -223,6 +224,19 @@
"empty_state_description": "No members yet.",
"empty_description": "Members will appear here once users log in and adopt nodes."
},
"channels": {
"title": "Channels",
"add_channel": "Add Channel",
"edit_channel": "Edit Channel",
"delete_channel": "Delete Channel",
"delete_confirm": "Are you sure you want to delete channel {{name}}?",
"name_label": "Channel Name",
"key_label": "Channel Key (hex)",
"visibility_label": "Visibility",
"enabled_label": "Enabled",
"channel_hash_label": "Hash",
"disabled": "Disabled"
},
"not_found": {
"description": "The page you're looking for doesn't exist or has been moved."
},
+3
View File
@@ -70,6 +70,9 @@
{% if features.messages %}
<li><a href="/messages" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-messages" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" /></svg> {{ t('entities.messages') }}</a></li>
{% endif %}
{% if features.channels %}
<li><a href="/channels" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg> {{ t('entities.channels') }}</a></li>
{% endif %}
{% if features.map %}
<li><a href="/map" data-nav-link><svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 nav-icon-map" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" /></svg> {{ t('entities.map') }}</a></li>
{% endif %}
+1 -1
View File
@@ -311,7 +311,7 @@ def sample_message_with_receiver(api_db_session, receiver_node):
"""Create a message with a receiver node."""
message = Message(
message_type="channel",
channel_idx=1,
channel_idx=17,
pubkey_prefix="xyz789",
text="Channel message with receiver",
received_at=datetime.now(timezone.utc),
+7 -7
View File
@@ -112,7 +112,7 @@ class TestListMessages:
"""Messages include observers list in response."""
msg = Message(
message_type="channel",
channel_idx=1,
channel_idx=17,
text="Msg with observer",
received_at=datetime.now(timezone.utc),
observer_node_id=receiver_node.id,
@@ -158,7 +158,7 @@ class TestGetMessage:
"""Get message includes observers list."""
msg = Message(
message_type="channel",
channel_idx=1,
channel_idx=17,
text="Msg for get observer test",
received_at=datetime.now(timezone.utc),
observer_node_id=receiver_node.id,
@@ -204,11 +204,11 @@ class TestListMessagesFilters:
):
"""Test filtering messages by channel_idx."""
# Channel 1 should match sample_message_with_receiver
response = client_no_auth.get("/api/v1/messages?channel_idx=1")
response = client_no_auth.get("/api/v1/messages?channel_idx=17")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["items"][0]["channel_idx"] == 1
assert data["items"][0]["channel_idx"] == 17
# Channel 0 should return no results
response = client_no_auth.get("/api/v1/messages?channel_idx=0")
@@ -251,14 +251,14 @@ class TestListMessagesFilters:
# Create two messages, each observed by a different receiver
msg1 = Message(
message_type="channel",
channel_idx=1,
channel_idx=17,
text="Msg from receiver A",
received_at=datetime.now(timezone.utc),
observer_node_id=receiver_node.id,
)
msg2 = Message(
message_type="channel",
channel_idx=2,
channel_idx=17,
text="Msg from receiver B",
received_at=datetime.now(timezone.utc),
observer_node_id=second_receiver.id,
@@ -381,7 +381,7 @@ class TestMessageSort:
now = datetime.now(timezone.utc)
msg_ch = Message(
message_type="channel",
channel_idx=1,
channel_idx=17,
text="Channel msg",
received_at=now,
)
+1 -1
View File
@@ -402,8 +402,8 @@ class TestSubscriber:
subscriber = Subscriber(
mock_mqtt_client,
db_manager,
include_test_channel=True,
)
subscriber._include_test_channel = True
handler = MagicMock()
subscriber.register_handler("channel_msg_recv", handler)
subscriber.start()
+10 -8
View File
@@ -59,18 +59,20 @@ class TestCollectorSettings:
assert settings.effective_seed_home == "/seed/data"
assert settings.node_tags_file == "/seed/data/node_tags.yaml"
def test_collector_channel_keys_list(self) -> None:
"""Channel keys are parsed from comma/space-separated env values."""
def test_channel_refresh_interval_seconds(self) -> None:
"""Channel refresh interval defaults to 300."""
settings = CollectorSettings(_env_file=None)
assert settings.channel_refresh_interval_seconds == 300
def test_channel_refresh_interval_seconds_custom(self) -> None:
"""Channel refresh interval can be overridden."""
settings = CollectorSettings(
_env_file=None,
collector_channel_keys="aa11, bb22 cc33",
channel_refresh_interval_seconds=60,
)
assert settings.collector_channel_keys_list == [
"aa11",
"bb22",
"cc33",
]
assert settings.channel_refresh_interval_seconds == 60
class TestAPISettings:
-2
View File
@@ -320,7 +320,6 @@ def web_app(mock_http_client: MockHttpClient, monkeypatch: pytest.MonkeyPatch) -
"""Create a web app with mocked HTTP client."""
# Ensure tests use a consistent locale regardless of local .env
monkeypatch.setenv("WEB_DATETIME_LOCALE", "en-US")
monkeypatch.setenv("COLLECTOR_INCLUDE_TEST_CHANNEL", "true")
monkeypatch.setenv("OIDC_ENABLED", "false")
monkeypatch.setenv("NETWORK_ANNOUNCEMENT", "")
app = create_app(
@@ -366,7 +365,6 @@ def web_app_with_oidc(
)
monkeypatch.setenv("OIDC_SESSION_SECRET", "test-session-secret")
monkeypatch.setenv("WEB_DATETIME_LOCALE", "en-US")
monkeypatch.setenv("COLLECTOR_INCLUDE_TEST_CHANNEL", "true")
monkeypatch.setenv("NETWORK_ANNOUNCEMENT", "")
app = create_app(