Files
stroblmeandClaude Opus 5 8cb843eb25 Make revoking an agent and locking a dashboard actually revoke and lock
An MCP access token is a stateless JWT good until it expires, so deleting
the client row revoked nothing already handed out — on the MCP endpoint or
on the REST API, which takes the same token directly. Both doors now look
the client up by the `client_id` the token has always carried, so tokens
already in circulation are held to it too.

A dashboard's `locked` setting stopped the client drawing a control and
nothing else; the server took a publish from a panel showing it anyway. It
now bounds the panel's write scope, resolved live where a flow drives the
flag, exactly as the client resolves it. Reads are untouched — read-only is
not blind — and so is a querying chart's request, which is how that tile
reads rather than something anyone touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CL9zvnnvcp1mvA8o7impxk
2026-09-06 15:24:53 +02:00

163 lines
5.9 KiB
Python

"""Panels: which dashboards a given device shows.
A wall tablet in the hall and one in the workshop want different dashboards,
and the same dashboard may hang on both. Rather than nesting pages inside a
dashboard, a panel names an ordered set of whole dashboards — each keeps its
own canvas, its own draft and its own version, and the device switches between
them through a rail.
Stored beside the flows rather than in them, like the alerting configuration:
which screen hangs where is the deployment's concern, not any one dashboard's.
"""
from __future__ import annotations
import threading
from pathlib import Path
from pydantic import BaseModel, Field, field_validator
from fluksio.core.config import settings
from fluksio.flow.dashboards import DashboardDef, DashboardNotFound, DashboardStore
from fluksio.flow.schemas import _validate_name
class PanelDef(BaseModel):
"""One device, and what it shows."""
id: str
title: str = ""
#: Ordered. The first one is what the device opens after pairing, and the
#: rail follows this order. A name that no longer resolves is simply a
#: dashboard someone deleted; the panel skips it.
dashboards: list[str] = Field(default_factory=list)
#: Bigger controls, a bigger rail and no hover states, for a screen that is
#: touched rather than pointed at. It belongs to the device rather than to
#: any dashboard: the same dashboard may hang on a hallway tablet and on a
#: desk browser, and only one of them has fingers on it.
touch: bool = False
#: Which generation of credential this panel honours. A token names the
#: nonce it was minted at, so bumping this refuses the screen currently
#: hanging here and leaves the panel, its dashboards and their arrangement
#: exactly as they are — re-pairing one device without deleting anything.
#: Not settable from outside: a save carries the stored value forward.
nonce: int = 0
@field_validator("id")
@classmethod
def _check_id(cls, value: str) -> str:
return _validate_name(value)
class PanelsConfig(BaseModel):
"""Every panel this instance knows about."""
panels: list[PanelDef] = Field(default_factory=list)
#: Held across a read-modify-write of the panels file.
#:
#: Both `save_panels` and `unpair_panel` are one of those, and interleaving
#: them silently undid a revocation: a save that read the file before an
#: unpair wrote it put the old nonce back, and the screen that had just been
#: unpaired kept working. The nonce carry-forward in the save handler was
#: written to make that impossible, and the window between its read and its
#: write is where it happened anyway.
edit_lock = threading.Lock()
def _path() -> Path:
return settings.PANELS_FILE
def read_config() -> PanelsConfig:
"""The stored panels, or none. Blocking."""
path = _path()
if not path.exists():
return PanelsConfig()
try:
return PanelsConfig.model_validate_json(path.read_text())
except Exception:
# A hand-edited file that no longer parses must not lock everyone out.
return PanelsConfig()
def write_config(config: PanelsConfig) -> None:
"""Blocking."""
path = _path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(config.model_dump_json(indent=2))
def find(panel_id: str) -> PanelDef | None:
"""The panel by that id, or None if it was removed.
Read from disk on every call: this is what makes deleting a panel revoke
its credential, so it has to see the current file rather than a cache.
"""
for panel in read_config().panels:
if panel.id == panel_id:
return panel
return None
def dashboards_for(panel_id: str, store: DashboardStore) -> list[DashboardDef]:
"""The published documents this panel shows, in rail order.
Published, since that is what a panel draws. A name that no longer
resolves is a dashboard someone deleted and is skipped, and a panel that
is gone shows nothing.
Handed back whole rather than walked here, because what a panel may do
with a message depends on the document it came from — a dashboard that
says it is locked entitles a screen to read it and not to touch it.
"""
panel = find(panel_id)
if panel is None:
return []
found: list[DashboardDef] = []
for name in panel.dashboards:
try:
found.append(store.read(name))
except DashboardNotFound:
continue
return found
def messages_of(defn: DashboardDef) -> set[str]:
"""Every message one dashboard reads or writes.
A dashboard's own bound settings count, not only its widgets': the theme a
panel is driven to is a message no tile on it draws, and a wall panel
refused its own theme message is the one surface the setting exists for.
"""
names = set(defn.setting_messages)
for widget in defn.widgets:
names.update(widget.messages)
if widget.target:
names.add(widget.target)
return names
def requests_of(defn: DashboardDef) -> set[str]:
"""The publishes this dashboard makes in order to read.
A querying chart asks a flow for the series it draws by publishing a
request, so that publish is how the tile reads rather than something
anyone touched. Every other message a dashboard sends comes from a
control, which is what marking it read-only turns off — so this is what a
locked dashboard is still entitled to send.
"""
return {w.target for w in defn.widgets if w.type == "chart" and w.target}
def messages_for(panel_id: str, store: DashboardStore) -> set[str]:
"""Every message this panel's dashboards read or write.
What a screen is entitled to see, as its own dashboards define it, and
empty for a panel that is gone — which is the same answer as "nothing".
"""
return {
name for defn in dashboards_for(panel_id, store) for name in messages_of(defn)
}