fix(shell): daemon-start and health-poll auth handling; policy panel freshness
Some checks failed
Security / Security check (push) Failing after 2s

Review follow-ups on the auth-status work:

- Both daemon-start paths classified an auth-rejected hub as "daemon
  dead" via healthy() and showed a start-failure dialog while the
  shell banner above correctly blamed the token. They now share
  daemonAnswers(): only an unreachable probe counts as down.
- An auth-rejected poll now re-reads ~/.chain/hub-auth-token and
  reconnects when the file changed, so a token fixed outside Studio
  (CLI, editor) heals the connection without a restart — previously
  the client kept the stale in-memory token forever and the banner's
  own advice could not work.
- An endpoint switch resets the failure streak, so a stale in-flight
  probe can no longer let the unreachable banner blame the new
  endpoint for the old one's misses.
- The auth-policy panel re-queries when the hub token is saved or
  cleared in the panel above (reloadTick), instead of keeping a
  stale admin-denied hint; it also renders the hub's new
  reload_required flag as a pending-reload warning (DE+EN).
- today-pipeline.md still documented ~/.fai/today after the rename;
  the FAB theme comment now states the both-themes intent.

flutter analyze clean; 71 tests green including four new ones.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-15 05:50:24 +02:00
parent 2a1cbc82c3
commit 7cc8bab9b9
13 changed files with 242 additions and 16 deletions

View file

@ -162,6 +162,7 @@ class HubService {
? await HubAuthToken.read()
: authToken as String?;
_client = HubClient(endpoint: endpoint, authToken: token);
_lastAuthToken = token;
if (persist) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kHostKey, endpoint.host);
@ -177,6 +178,23 @@ class HubService {
await reconnect(_client.endpoint);
}
/// The token the live client was built with. The client caches it,
/// so an edit to `~/.chain/hub-auth-token` outside Studio is
/// invisible until a reconnect see [reloadAuthTokenIfChanged].
String? _lastAuthToken;
/// Re-read the token file and reconnect ONLY when its content
/// differs from the token the live client uses. Returns whether a
/// reconnect happened. The shell's health poll calls this on an
/// auth-rejected probe so a token fixed outside Studio (CLI,
/// editor) heals the connection without a restart.
Future<bool> reloadAuthTokenIfChanged() async {
final fresh = await HubAuthToken.read();
if (fresh == _lastAuthToken) return false;
await reconnect(_client.endpoint, authToken: fresh, persist: false);
return true;
}
static const _kThemeKey = 'theme.mode';
static const _kLocaleKey = 'locale.code';
@ -363,6 +381,7 @@ class HubService {
scopeClaim: r.jwt.scopeClaim,
)
: null,
reloadRequired: r.reloadRequired,
);
}
@ -1685,11 +1704,16 @@ class HubAuthPolicy {
/// jwt-rs256 parameters; null for the static validator.
final HubJwtValidatorInfo? jwt;
/// True when the on-disk auth config (or its env vars) no longer
/// matches the validator the hub enforces a pending reload.
final bool reloadRequired;
const HubAuthPolicy({
required this.validator,
required this.anonymousAllowed,
required this.tokens,
this.jwt,
this.reloadRequired = false,
});
}

View file

@ -835,6 +835,7 @@
"authPolicyValidatorStatic": "Prüfverfahren: statische Token-Liste",
"authPolicyValidatorJwt": "Prüfverfahren: JWT (RS256) über eine externe Identitätsstelle",
"authPolicyAnonymous": "Keine Tokens konfiguriert — der Hub akzeptiert anonyme Aufrufe. Für lokales Arbeiten in Ordnung; für den Produktivbetrieb Tokens in ~/.chain/config.yaml einrichten.",
"authPolicyReloadRequired": "Die Konfigurationsdatei (oder eine Umgebungsvariable) wurde seit dem Laden geändert — der Hub erzwingt noch den alten Stand. Unten „Neu laden“ wählen, damit die Änderung wirkt.",
"authPolicyNeedsAdmin": "Diese Ansicht braucht ein Token mit admin-Recht. Hinterlegen Sie es oben unter „Hub-Authentifizierung“ und laden Sie neu.",
"authPolicyHubTooOld": "Der verbundene Hub kennt diese Ansicht noch nicht — er ist älter als Studio. Aktualisieren Sie den Hub (chain update apply) und laden Sie neu.",
"authPolicyRetry": "Erneut versuchen",

View file

@ -853,6 +853,7 @@
"authPolicyValidatorStatic": "Validator: static token list",
"authPolicyValidatorJwt": "Validator: JWT (RS256) via an external identity provider",
"authPolicyAnonymous": "No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.",
"authPolicyReloadRequired": "The config file (or an environment variable) changed after loading — the hub still enforces the previous state. Use “Reload” below to apply the change.",
"authPolicyNeedsAdmin": "This view needs a token with the admin scope. Store it above under “Hub authentication” and reload.",
"authPolicyHubTooOld": "The connected hub does not know this view yet — it is older than Studio. Update the hub (chain update apply) and reload.",
"authPolicyRetry": "Retry",

View file

@ -2654,6 +2654,12 @@ abstract class AppLocalizations {
/// **'No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.'**
String get authPolicyAnonymous;
/// No description provided for @authPolicyReloadRequired.
///
/// In en, this message translates to:
/// **'The config file (or an environment variable) changed after loading — the hub still enforces the previous state. Use “Reload” below to apply the change.'**
String get authPolicyReloadRequired;
/// No description provided for @authPolicyNeedsAdmin.
///
/// In en, this message translates to:

View file

@ -1514,6 +1514,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get authPolicyAnonymous =>
'Keine Tokens konfiguriert — der Hub akzeptiert anonyme Aufrufe. Für lokales Arbeiten in Ordnung; für den Produktivbetrieb Tokens in ~/.chain/config.yaml einrichten.';
@override
String get authPolicyReloadRequired =>
'Die Konfigurationsdatei (oder eine Umgebungsvariable) wurde seit dem Laden geändert — der Hub erzwingt noch den alten Stand. Unten „Neu laden“ wählen, damit die Änderung wirkt.';
@override
String get authPolicyNeedsAdmin =>
'Diese Ansicht braucht ein Token mit admin-Recht. Hinterlegen Sie es oben unter „Hub-Authentifizierung“ und laden Sie neu.';

View file

@ -1527,6 +1527,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get authPolicyAnonymous =>
'No tokens configured — the hub accepts anonymous calls. Fine for local work; configure tokens in ~/.chain/config.yaml for production.';
@override
String get authPolicyReloadRequired =>
'The config file (or an environment variable) changed after loading — the hub still enforces the previous state. Use “Reload” below to apply the change.';
@override
String get authPolicyNeedsAdmin =>
'This view needs a token with the admin scope. Store it above under “Hub authentication” and reload.';

View file

@ -270,6 +270,11 @@ class StudioShellState extends State<StudioShell> {
int _failedPolls = 0;
static const int _unreachableThreshold = 3;
/// Endpoint the previous poll ran against; a change resets the
/// failure streak so stale in-flight results never blame the new
/// endpoint (see `_checkHealth`).
String _polledEndpoint = '';
/// Whether to render the unreachable banner. Distinct from
/// `_connected == false` so a single transient miss doesn't
/// flash the banner only sustained failure does.
@ -287,6 +292,44 @@ class StudioShellState extends State<StudioShell> {
@visibleForTesting
static Future<HubProbeResult> Function()? debugProbeOverride;
/// Test-only: replaces the token-file self-heal check (see
/// [_reloadTokenIfChanged]) so widget tests can assert the
/// auth-rejected poll re-reads the token without touching disk.
@visibleForTesting
static Future<bool> Function()? debugReloadTokenOverride;
/// Whether the hub daemon answers at all an auth rejection or a
/// not-serving health state still means the process is alive, just
/// not usable yet; only [HubProbeResult.unreachable] is "down".
/// Used by the daemon-start paths so a wrong token doesn't
/// misreport as "daemon start failed" (the shell banner already
/// explains the token problem).
static Future<bool> daemonAnswers() async {
try {
final probe = debugProbeOverride != null
? await debugProbeOverride!()
: await HubService.instance.probeHealth();
return probe != HubProbeResult.unreachable;
} catch (_) {
return false;
}
}
/// Self-heal after an auth-rejected poll: the operator may have
/// fixed `~/.chain/hub-auth-token` outside Studio (CLI, editor)
/// the client caches the token, so re-read the file and reconnect
/// when it changed. Returns whether a reconnect happened. Inert
/// when the probe is test-overridden, unless the reload override
/// is set too.
static Future<bool> _reloadTokenIfChanged() {
final override = debugReloadTokenOverride;
if (override != null) return override();
if (debugProbeOverride != null) return Future.value(false);
return HubService.instance
.reloadAuthTokenIfChanged()
.catchError((_) => false);
}
/// Current connection state, for descendants (e.g. WelcomePage)
/// that adapt their content to hub availability.
bool? get connected => _connected;
@ -307,9 +350,9 @@ class StudioShellState extends State<StudioShell> {
}
// The start command reported failure but very often the daemon is
// simply already running (port in use). Probe before crying error,
// so "tap to start" on an already-up hub just connects.
final alive =
await HubService.instance.healthy().catchError((_) => false);
// so "tap to start" on an already-up hub just connects. Alive but
// auth-rejected counts as running the banner explains the token.
final alive = await daemonAnswers();
if (!mounted) return;
if (alive) {
await _checkHealth();
@ -422,15 +465,22 @@ class StudioShellState extends State<StudioShell> {
);
}
Future<void> _checkHealth() async {
Future<void> _checkHealth({bool retriedAfterTokenReload = false}) async {
final probe = debugProbeOverride != null
? await debugProbeOverride!()
: await HubService.instance.probeHealth();
if (!mounted) return;
// A failure that raced an endpoint switch (Settings save, channel
// switch) must not inherit the old endpoint's failure streak —
// restart the count so the banner never blames the NEW endpoint
// for the OLD one's misses.
final endpoint = HubService.instance.endpointLabel;
final endpointChanged = endpoint != _polledEndpoint;
_polledEndpoint = endpoint;
final ok = probe == HubProbeResult.serving;
final authRejected = probe == HubProbeResult.authRejected;
final wasUnreachable = _hubUnreachable;
final nextFailed = ok ? 0 : _failedPolls + 1;
final nextFailed = ok ? 0 : (endpointChanged ? 1 : _failedPolls + 1);
final connectionChanged = _connected != ok;
final bannerChanged =
wasUnreachable != (nextFailed >= _unreachableThreshold);
@ -444,6 +494,16 @@ class StudioShellState extends State<StudioShell> {
_authRejected = authRejected;
});
}
if (authRejected && !retriedAfterTokenReload) {
// Self-heal: re-read the token file in case it was fixed
// outside Studio; on a real change reconnect + re-probe now
// instead of waiting out the poll interval. One retry per
// poll tick at most.
final changed = await _reloadTokenIfChanged();
if (changed && mounted) {
return _checkHealth(retriedAfterTokenReload: true);
}
}
if (ok) {
try {
final snap = await HubService.instance.channelStatus();
@ -779,8 +839,9 @@ class _SidebarState extends State<_Sidebar>
}
// The daemon may already be running (port in use) probe before
// showing an error, so this just connects on an already-up hub.
final alive =
await HubService.instance.healthy().catchError((_) => false);
// Alive but auth-rejected counts as running the shell banner
// explains the token problem.
final alive = await StudioShellState.daemonAnswers();
if (!context.mounted) return;
if (alive) return;
// Genuinely down: persistent, copyable dialog (a SnackBar flashes

View file

@ -265,9 +265,11 @@ class ChainTheme {
),
margin: EdgeInsets.zero,
),
// Match FABs to the filled-button accent: Material 3 defaults
// them to primaryContainer, which reads as a washed-out tonal
// surface next to the sky-700 CTAs in the light theme.
// Match FABs to the filled-button accent in BOTH themes:
// Material 3 defaults them to primaryContainer, a tonal
// surface that reads washed-out next to the primary CTAs
// (visually verified light and dark; the dark FAB follows
// the dark filled buttons on purpose).
floatingActionButtonTheme: FloatingActionButtonThemeData(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,

View file

@ -64,6 +64,10 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
/// after save / clear; UI only ever sees the trimmed length.
int? _hubAuthTokenChars;
/// Bumped whenever the hub auth token is saved or cleared so the
/// auth-policy panel below re-queries with the new credentials.
int _authPolicyReloadTick = 0;
@override
void initState() {
super.initState();
@ -95,6 +99,9 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
await HubService.instance.reloadAuthToken();
await _loadHubAuthToken();
if (!mounted) return;
// The connection's auth just changed — the policy panel below
// must re-query instead of keeping a stale admin-denied hint.
setState(() => _authPolicyReloadTick++);
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
@ -111,6 +118,7 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
await HubService.instance.reloadAuthToken();
await _loadHubAuthToken();
if (!mounted) return;
setState(() => _authPolicyReloadTick++);
final l = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
@ -650,7 +658,7 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
onClear: _clearHubAuthToken,
),
const SizedBox(height: ChainSpace.xl),
const HubAuthPolicyPanel(),
HubAuthPolicyPanel(reloadTick: _authPolicyReloadTick),
];
}

View file

@ -23,7 +23,18 @@ class HubAuthPolicyPanel extends StatefulWidget {
@visibleForTesting
final Future<int> Function()? reloader;
const HubAuthPolicyPanel({super.key, this.loader, this.reloader});
/// Bumped by the parent whenever an adjacent action changed the
/// connection's auth state (token saved or cleared in the panel
/// above) the policy view reloads instead of showing a stale
/// "store an admin token" hint next to the just-stored token.
final int reloadTick;
const HubAuthPolicyPanel({
super.key,
this.loader,
this.reloader,
this.reloadTick = 0,
});
@override
State<HubAuthPolicyPanel> createState() => _HubAuthPolicyPanelState();
@ -42,6 +53,14 @@ class _HubAuthPolicyPanelState extends State<HubAuthPolicyPanel> {
_load();
}
@override
void didUpdateWidget(covariant HubAuthPolicyPanel oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.reloadTick != oldWidget.reloadTick) {
_load();
}
}
Future<void> _load() async {
setState(() {
_loading = true;
@ -193,6 +212,15 @@ class _HubAuthPolicyPanelState extends State<HubAuthPolicyPanel> {
color: theme.colorScheme.tertiary,
),
],
if (p.reloadRequired) ...[
const SizedBox(height: ChainSpace.xs),
_hintRow(
theme,
Icons.sync_problem_outlined,
l.authPolicyReloadRequired,
color: theme.colorScheme.tertiary,
),
],
if (p.tokens.isNotEmpty) ...[
const SizedBox(height: ChainSpace.sm),
for (final t in p.tokens) _tokenRow(theme, l, t),