diff --git a/integration_test/auth_policy_shots_test.dart b/integration_test/auth_policy_shots_test.dart new file mode 100644 index 0000000..72e1e61 --- /dev/null +++ b/integration_test/auth_policy_shots_test.dart @@ -0,0 +1,187 @@ +// Screenshot proof for Settings → Security → hub auth policy +// (the T4/T5 parity panel): boots a hermetic hub WITH token auth +// (fixture config via $CHAIN_CONFIG, secrets via env), connects +// as the admin token, opens the settings dialog on the Security +// category and captures the rendered policy — validator, token +// cards with scope grants, the env-not-set rotation warning. +// +// Like guide_shots_test.dart this renders through a +// RepaintBoundary, so it works headed on macOS with plain +// `flutter test` and does not depend on the screen being +// unlocked. Theme / locale come from the same env knobs: +// +// GUIDE_SHOTS_THEME=light GUIDE_SHOTS_LOCALE=en \ +// flutter test integration_test/auth_policy_shots_test.dart -d macos +// +// Output lands in build/guide-shots/ unless GUIDE_SHOTS_OUT is set. + +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:chain_client_sdk/chain_client_sdk.dart' show HubEndpoint; +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/main.dart'; +import 'package:chain_studio/widgets/widgets.dart'; + +import '../test/integration/hub_fixture.dart'; + +final GlobalKey _shotKey = GlobalKey(); + +const _adminSecret = 'shot-admin-secret'; + +/// The fixture hub's auth policy. studio-admin is the caller, +/// digiscout-prod shows a fine-grained T5 scope + rate limit. +/// ci-reader is appended AFTER boot (the hub refuses to start on +/// an unset token_env, but AuthStatus re-reads the config live) — +/// that's the rotation footgun the panel exists to surface. +const _configYaml = ''' +auth: + tokens: + - name: studio-admin + token_env: CHAIN_TOKEN_STUDIO_ADMIN + scopes: [admin, read, execute, install] + - name: digiscout-prod + token_env: CHAIN_TOKEN_DIGISCOUT + scopes: [read, "execute:llm.*"] + rate_limit_per_minute: 120 +'''; + +const _ciReaderYaml = ''' + - name: ci-reader + token_env: CHAIN_TOKEN_CI + scopes: [read] +'''; + +Future _pumpFrames(WidgetTester tester, [int frames = 20]) async { + for (var i = 0; i < frames; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } +} + +Future _pumpUntil( + WidgetTester tester, + Finder finder, { + int maxFrames = 100, +}) async { + for (var i = 0; i < maxFrames; i++) { + if (finder.evaluate().isNotEmpty) return; + await tester.pump(const Duration(milliseconds: 100)); + } +} + +String get _outDir => + Platform.environment['GUIDE_SHOTS_OUT'] ?? 'build/guide-shots'; + +ThemeModeValue get _theme => + Platform.environment['GUIDE_SHOTS_THEME'] == 'light' + ? ThemeModeValue.light + : ThemeModeValue.dark; + +Locale get _locale => Locale( + Platform.environment['GUIDE_SHOTS_LOCALE'] == 'en' ? 'en' : 'de', +); + +Future _shot(WidgetTester tester, String name) async { + await tester.pump(const Duration(milliseconds: 120)); + await tester.pump(const Duration(milliseconds: 120)); + final boundary = + _shotKey.currentContext!.findRenderObject() as RenderRepaintBoundary; + final image = await boundary.toImage(pixelRatio: 2.0); + final bytes = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + final file = File('$_outDir/$name.png'); + file.parent.createSync(recursive: true); + file.writeAsBytesSync(bytes!.buffer.asUint8List()); + // ignore: avoid_print + print('guide-shot: ${file.path}'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('capture the Settings → Security auth-policy panel', + (tester) async { + final fixture = await HubFixture.start( + skipIfBinaryMissing: false, + configYaml: _configYaml, + extraEnvironment: { + 'CHAIN_TOKEN_STUDIO_ADMIN': _adminSecret, + 'CHAIN_TOKEN_DIGISCOUT': 'shot-digiscout-secret', + }, + authToken: _adminSecret, + ); + addTearDown(fixture!.dispose); + + // Rotation scenario: a token added to the config after the + // daemon started, its env var not exported yet → the panel + // must flag it (env_set=false). + File('${fixture.tempDir.path}/config.yaml') + .writeAsStringSync(_configYaml + _ciReaderYaml); + + // Hermetic prefs: never read or write the operator's real ones. + SharedPreferences.setMockInitialValues({}); + + await tester.pumpWidget( + RepaintBoundary( + key: _shotKey, + child: StudioApp( + initialThemeMode: _theme, + initialLocale: _locale, + ), + ), + ); + await HubService.instance.reconnect( + HubEndpoint(host: '127.0.0.1', port: fixture.port), + authToken: _adminSecret, + persist: false, + ); + await _pumpFrames(tester, 30); + + // Settings gear (sidebar) → Security category. + await tester.tap(find.byIcon(Icons.settings_outlined).first); + await _pumpFrames(tester); + final securityTab = find.text( + _locale.languageCode == 'en' ? 'Security' : 'Sicherheit', + ); + await _pumpUntil(tester, securityTab); + await tester.tap(securityTab.first); + await _pumpFrames(tester); + + // The policy loads from the hub asynchronously; the ci-reader + // card is the last to prove the full token list arrived. + await _pumpUntil(tester, find.textContaining('ci-reader')); + + // Bring the panel into view — it sits below the registry + + // hub-token sections in the Security category's scroll area. + if (find.textContaining('ci-reader').evaluate().isNotEmpty) { + await tester.scrollUntilVisible( + find.textContaining('ci-reader').first, + 120, + scrollable: find + .descendant( + of: find.byType(ChainSettingsDialog), + matching: find.byType(Scrollable), + ) + .first, + ); + await _pumpFrames(tester, 5); + } + + // Capture before asserting so a failing run still leaves a + // diagnosable image behind. + final suffix = + '${_theme == ThemeModeValue.light ? 'hell' : 'dunkel'}-${_locale.languageCode}'; + await _shot(tester, 'einstellungen-sicherheit-$suffix'); + + expect(find.textContaining('studio-admin'), findsWidgets); + expect(find.textContaining('digiscout-prod'), findsWidgets); + expect(find.textContaining('execute:llm.*'), findsWidgets); + expect(find.textContaining('ci-reader'), findsWidgets); + }); +} diff --git a/lib/data/chain_log.dart b/lib/data/chain_log.dart index d3411bf..789da8f 100644 --- a/lib/data/chain_log.dart +++ b/lib/data/chain_log.dart @@ -51,31 +51,7 @@ class ChainLog { Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? '.'; - final path = p.join(home, '.chain', 'logs', 'studio-errors.log'); - _migrateLegacyLog(home, path); - return path; - } - - // Pre-rename installs wrote to `~/.fai/logs/`. Move that file (and - // its rotation sibling) over once so the error trail survives the - // rename; never overwrite an existing new-path file. Best-effort - // and cheap enough to run per access (two stat calls after the - // first migration). - static void _migrateLegacyLog(String home, String newPath) { - try { - for (final suffix in const ['', '.1']) { - final legacy = File( - p.join(home, '.fai', 'logs', 'studio-errors.log$suffix'), - ); - final target = File('$newPath$suffix'); - if (legacy.existsSync() && !target.existsSync()) { - target.parent.createSync(recursive: true); - legacy.renameSync(target.path); - } - } - } catch (_) { - // Best-effort: a failed migration must not break logging. - } + return p.join(home, '.chain', 'logs', 'studio-errors.log'); } /// Absolute path of the studio-errors log. Public so the diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 86fd12d..ac140ce 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -331,6 +331,42 @@ class HubService { ); } + /// Read-only snapshot of the hub's authentication policy + /// (validator kind, anonymous flag, token entries with scope + /// grants and env-var set-state — never secret values). Feeds + /// Settings → Security. Admin-scoped on an auth-enabled hub. + Future authStatus() async { + final r = await _client.authStatus(); + return HubAuthPolicy( + validator: r.validator, + anonymousAllowed: r.anonymousAllowed, + tokens: r.tokens + .map( + (t) => HubAuthTokenEntry( + name: t.name, + tokenEnv: t.tokenEnv, + envSet: t.envSet, + scopes: List.from(t.scopes), + rateLimitPerMinute: t.rateLimitPerMinute, + ), + ) + .toList(), + jwt: r.hasJwt() + ? HubJwtValidatorInfo( + keySource: r.jwt.keySource, + audience: r.jwt.audience, + issuer: r.jwt.issuer, + scopeClaim: r.jwt.scopeClaim, + ) + : null, + ); + } + + /// Re-read `auth.tokens:` from operator config + env vars and + /// swap the hub's live token store (rotation without restart). + /// Returns the new token count. + Future reloadHubAuth() => _client.reloadAuth(); + /// Send a one-shot prompt to the System AI. Returns the /// answer + latency on success or an [AskAiResult] with /// `errorKind` set on failure (never throws). Studio maps @@ -794,7 +830,7 @@ class HubService { .toList(); } - /// Install a module from a `.fai` bundle (URL or local path). + /// Install a module from a `.chain` bundle (URL or local path). /// Returns the installed module's name + version on success; /// throws on hub error (signature mismatch, sha256 fail, etc.). Future<({String name, String version})> installModule({ @@ -1631,6 +1667,67 @@ class DetachedRun { } } +/// Snapshot of the hub's authentication policy (AuthStatus RPC). +/// Names + grants only — secret values never reach the client. +class HubAuthPolicy { + /// "static" or "jwt-rs256". + final String validator; + + /// True when the hub accepts anonymous calls (static validator + /// with an empty token list — the local-dev default). + final bool anonymousAllowed; + final List tokens; + + /// jwt-rs256 parameters; null for the static validator. + final HubJwtValidatorInfo? jwt; + + const HubAuthPolicy({ + required this.validator, + required this.anonymousAllowed, + required this.tokens, + this.jwt, + }); +} + +class HubAuthTokenEntry { + final String name; + final String tokenEnv; + + /// Whether the env var is currently set in the daemon's + /// environment — the classic rotation footgun. + final bool envSet; + + /// Scope grants verbatim (`read`, `execute`, `admin`, and + /// fine-grained patterns like `execute:llm.*`). + final List scopes; + + /// Requests per minute; 0 = unlimited. + final int rateLimitPerMinute; + + const HubAuthTokenEntry({ + required this.name, + required this.tokenEnv, + required this.envSet, + required this.scopes, + required this.rateLimitPerMinute, + }); +} + +class HubJwtValidatorInfo { + /// "inline-pem" or the configured public-key file path. + final String keySource; + final String audience; + final String issuer; + final String scopeClaim; + + const HubJwtValidatorInfo({ + required this.keySource, + required this.audience, + required this.issuer, + required this.scopeClaim, + }); +} + class SystemAiStatus { final bool enabled; final String provider; diff --git a/lib/data/hub_auth_token.dart b/lib/data/hub_auth_token.dart index 7ef92f5..8b45c1d 100644 --- a/lib/data/hub_auth_token.dart +++ b/lib/data/hub_auth_token.dart @@ -14,17 +14,17 @@ import 'package:path/path.dart' as p; /// anonymous mode; the hub then either accepts or rejects /// based on its own `auth.tokens:` config. class HubAuthToken { - static String _faiHome() { + static String _chainHome() { final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; if (home == null || home.isEmpty) { throw StateError('Cannot resolve home directory (no HOME / USERPROFILE)'); } - return p.join(home, '.fai'); + return p.join(home, '.chain'); } /// Absolute path to the token file. - static String get path => p.join(_faiHome(), 'hub-auth-token'); + static String get path => p.join(_chainHome(), 'hub-auth-token'); /// True iff the file exists with a non-empty trimmed body. static Future isConfigured() async { diff --git a/lib/data/registry_token.dart b/lib/data/registry_token.dart index 820b9c8..2cf7876 100644 --- a/lib/data/registry_token.dart +++ b/lib/data/registry_token.dart @@ -9,17 +9,17 @@ import 'package:path/path.dart' as p; /// Studio writes the file directly so a fresh install never /// requires the operator to fiddle with shell env vars. class RegistryToken { - static String _faiHome() { + static String _chainHome() { final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; if (home == null || home.isEmpty) { throw StateError('Cannot resolve home directory (no HOME / USERPROFILE)'); } - return p.join(home, '.fai'); + return p.join(home, '.chain'); } /// Absolute path to the token file. - static String get path => p.join(_faiHome(), 'registry-token'); + static String get path => p.join(_chainHome(), 'registry-token'); /// True iff the file exists with a non-empty trimmed body. static Future isConfigured() async { diff --git a/lib/data/today_story_loader.dart b/lib/data/today_story_loader.dart index 42ad8d6..a9b9e8b 100644 --- a/lib/data/today_story_loader.dart +++ b/lib/data/today_story_loader.dart @@ -80,7 +80,7 @@ class TodayStoryLoader { Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'] ?? ''; - return p.join(home, '.fai', 'today', 'active.yaml'); + return p.join(home, '.chain', 'today', 'active.yaml'); } /// Reads and validates the active story. Returns `fallback` diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 7f9979a..03179fd 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -804,7 +804,7 @@ } }, "registryCredentialsHeader": "REGISTRY-ZUGANGSDATEN", - "registryCredentialsBlurb": "Token zum Herunterladen von .fai-Modulen aus einer Registry mit Anmeldepflicht (Forgejo, private GitHub-Repos). Gespeichert in ~/.chain/registry-token, Modus 0600. Die Umgebungsvariable CHAIN_REGISTRY_TOKEN hat weiterhin Vorrang, wenn gesetzt.", + "registryCredentialsBlurb": "Token zum Herunterladen von .chain-Modulen aus einer Registry mit Anmeldepflicht (Forgejo, private GitHub-Repos). Gespeichert in ~/.chain/registry-token, Modus 0600. Die Umgebungsvariable CHAIN_REGISTRY_TOKEN hat weiterhin Vorrang, wenn gesetzt.", "registryTokenStatusConfigured": "Konfiguriert ({chars} Zeichen)", "@registryTokenStatusConfigured": { "placeholders": { @@ -829,6 +829,29 @@ } } }, + "authPolicyHeader": "ZUGRIFFSSCHUTZ DES HUBS", + "authPolicyBlurb": "Wie der Hub eingehende Aufrufe prüft: aktives Prüfverfahren, Tokens und ihre Rechte (Scopes). Geheimnisse bleiben in Umgebungsvariablen — hier erscheinen nur deren Namen.", + "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.", + "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", + "authPolicyReload": "Tokens neu laden", + "authPolicyReloadDone": "Neu geladen — {n} Token aktiv.", + "@authPolicyReloadDone": {"placeholders": {"n": {"type": "int"}}}, + "authPolicyEditHint": "Bearbeitet wird die Richtlinie in ~/.chain/config.yaml (Abschnitt auth:). Nach einer Änderung oder Token-Rotation hier neu laden — der Hub übernimmt sie ohne Neustart.", + "authPolicyEnvSet": "Umgebungsvariable {env} ist gesetzt", + "@authPolicyEnvSet": {"placeholders": {"env": {"type": "String"}}}, + "authPolicyEnvMissing": "Umgebungsvariable {env} FEHLT — das Token ist nicht nutzbar", + "@authPolicyEnvMissing": {"placeholders": {"env": {"type": "String"}}}, + "authPolicyRateLimit": "{n}/min", + "@authPolicyRateLimit": {"placeholders": {"n": {"type": "int"}}}, + "authPolicyJwtKeySource": "Schlüsselquelle", + "authPolicyJwtAudience": "Audience (aud)", + "authPolicyJwtIssuer": "Aussteller (iss)", + "authPolicyJwtScopeClaim": "Scope-Claim", + "authPolicyNotChecked": "wird nicht geprüft", "hubAuthTokenHeader": "HUB-AUTHENTIFIZIERUNG", "hubAuthTokenBlurb": "Bearer-Token für die Studio-Anbindung an einen Hub mit aktivierter auth.tokens-Konfiguration (RBAC Level 2). Gespeichert in ~/.chain/hub-auth-token, Modus 0600. Studio sendet ihn als Authorization: Bearer bei jedem gRPC-Aufruf.", "hubAuthTokenStatusConfigured": "Eingerichtet ({chars} Zeichen)", @@ -1554,16 +1577,16 @@ } }, "addSourceTitle": "Modul-Quelle hinzufügen", - "addSourceIntro": "`{capability}` ist nicht im öffentlichen Store. Zeig dem Hub eine `.fai`-Bundle-URL oder einen lokalen Bundle-Pfad — der Hub lädt es herunter, prüft (sha256 + Signatur) und installiert.", + "addSourceIntro": "`{capability}` ist nicht im öffentlichen Store. Zeig dem Hub eine `.chain`-Bundle-URL oder einen lokalen Bundle-Pfad — der Hub lädt es herunter, prüft (sha256 + Signatur) und installiert.", "@addSourceIntro": { "placeholders": { "capability": {"type": "String"} } }, - "addSourceField": "URL oder Pfad zum .fai-Bundle", - "addSourceHint": "https://git.flemming.ai/deine-org/dein-modul/releases/download/v0.1.0/foo-0.1.0.fai", + "addSourceField": "URL oder Pfad zum .chain-Bundle", + "addSourceHint": "https://git.flemming.ai/deine-org/dein-modul/releases/download/v0.1.0/foo-0.1.0.chain", "addSourceHowItWorksTitle": "Wie private Module funktionieren", - "addSourceHowItWorksBody": "Ein Modul ist ein Verzeichnis mit module.yaml + WASM-Artefakt. Zum Teilen: `chain pack ` baut ein `.fai`-Bundle, das du beliebig hosten kannst (eigene Forgejo / GitHub / S3). Der Hub installiert per URL und prüft die Signatur gegen seinen Trust-Store.\n\nLokal entwickeln? Nimm das CLI — Studio installiert (noch) nicht aus einem unverpackten Verzeichnis:", + "addSourceHowItWorksBody": "Ein Modul ist ein Verzeichnis mit module.yaml + WASM-Artefakt. Zum Teilen: `chain pack ` baut ein `.chain`-Bundle, das du beliebig hosten kannst (eigene Forgejo / GitHub / S3). Der Hub installiert per URL und prüft die Signatur gegen seinen Trust-Store.\n\nLokal entwickeln? Nimm das CLI — Studio installiert (noch) nicht aus einem unverpackten Verzeichnis:", "addSourceCliExample": "chain install --link /pfad/zum/modul", "addSourceInstallButton": "Installieren", "addSourceCancel": "Abbrechen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 00b0f1d..049e1de 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -822,7 +822,7 @@ } }, "registryCredentialsHeader": "REGISTRY CREDENTIALS", - "registryCredentialsBlurb": "Token used to download .fai modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.chain/registry-token, mode 0600. The CHAIN_REGISTRY_TOKEN env var still wins when set.", + "registryCredentialsBlurb": "Token used to download .chain modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.chain/registry-token, mode 0600. The CHAIN_REGISTRY_TOKEN env var still wins when set.", "registryTokenStatusConfigured": "Configured ({chars} chars)", "@registryTokenStatusConfigured": { "placeholders": { @@ -847,6 +847,29 @@ } } }, + "authPolicyHeader": "HUB ACCESS POLICY", + "authPolicyBlurb": "How the hub checks incoming calls: the active validator, the tokens and their scope grants. Secrets stay in environment variables — only their names appear here.", + "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.", + "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", + "authPolicyReload": "Reload tokens", + "authPolicyReloadDone": "Reloaded — {n} tokens active.", + "@authPolicyReloadDone": {"placeholders": {"n": {"type": "int"}}}, + "authPolicyEditHint": "The policy is edited in ~/.chain/config.yaml (auth: section). After a change or token rotation, reload here — the hub applies it without a restart.", + "authPolicyEnvSet": "Environment variable {env} is set", + "@authPolicyEnvSet": {"placeholders": {"env": {"type": "String"}}}, + "authPolicyEnvMissing": "Environment variable {env} is MISSING — the token is unusable", + "@authPolicyEnvMissing": {"placeholders": {"env": {"type": "String"}}}, + "authPolicyRateLimit": "{n}/min", + "@authPolicyRateLimit": {"placeholders": {"n": {"type": "int"}}}, + "authPolicyJwtKeySource": "Key source", + "authPolicyJwtAudience": "Audience (aud)", + "authPolicyJwtIssuer": "Issuer (iss)", + "authPolicyJwtScopeClaim": "Scope claim", + "authPolicyNotChecked": "not checked", "hubAuthTokenHeader": "HUB AUTHENTICATION", "hubAuthTokenBlurb": "Bearer token used to authenticate Studio against a hub that has auth.tokens: configured (RBAC Level 2). Stored at ~/.chain/hub-auth-token, mode 0600. Studio sends it as Authorization: Bearer on every gRPC call.", "hubAuthTokenStatusConfigured": "Configured ({chars} chars)", @@ -1578,16 +1601,16 @@ } }, "addSourceTitle": "Add module source", - "addSourceIntro": "`{capability}` is not in the public store. Point the hub at a `.fai` bundle URL or a local bundle path; the hub downloads, verifies (sha256 + signature) and installs it.", + "addSourceIntro": "`{capability}` is not in the public store. Point the hub at a `.chain` bundle URL or a local bundle path; the hub downloads, verifies (sha256 + signature) and installs it.", "@addSourceIntro": { "placeholders": { "capability": {"type": "String"} } }, - "addSourceField": "URL or path to .fai bundle", - "addSourceHint": "https://git.flemming.ai/your-org/your-module/releases/download/v0.1.0/foo-0.1.0.fai", + "addSourceField": "URL or path to .chain bundle", + "addSourceHint": "https://git.flemming.ai/your-org/your-module/releases/download/v0.1.0/foo-0.1.0.chain", "addSourceHowItWorksTitle": "How private modules work", - "addSourceHowItWorksBody": "A module is a directory with a module.yaml + the WASM artifact. To share it: pack it (`chain pack `) and host the resulting `.fai` bundle anywhere (your own Forgejo / GitHub / S3). The hub installs by URL and verifies the signature against its trust store.\n\nDeveloping locally? Use the CLI — Studio cannot install from an unpacked directory (yet):", + "addSourceHowItWorksBody": "A module is a directory with a module.yaml + the WASM artifact. To share it: pack it (`chain pack `) and host the resulting `.chain` bundle anywhere (your own Forgejo / GitHub / S3). The hub installs by URL and verifies the signature against its trust store.\n\nDeveloping locally? Use the CLI — Studio cannot install from an unpacked directory (yet):", "addSourceCliExample": "chain install --link /path/to/module", "addSourceInstallButton": "Install", "addSourceCancel": "Cancel", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index a2658ee..6f2d678 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2555,7 +2555,7 @@ abstract class AppLocalizations { /// No description provided for @registryCredentialsBlurb. /// /// In en, this message translates to: - /// **'Token used to download .fai modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.chain/registry-token, mode 0600. The CHAIN_REGISTRY_TOKEN env var still wins when set.'** + /// **'Token used to download .chain modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.chain/registry-token, mode 0600. The CHAIN_REGISTRY_TOKEN env var still wins when set.'** String get registryCredentialsBlurb; /// No description provided for @registryTokenStatusConfigured. @@ -2618,6 +2618,120 @@ abstract class AppLocalizations { /// **'Could not save: {error}'** String registryTokenSaveFailedToast(String error); + /// No description provided for @authPolicyHeader. + /// + /// In en, this message translates to: + /// **'HUB ACCESS POLICY'** + String get authPolicyHeader; + + /// No description provided for @authPolicyBlurb. + /// + /// In en, this message translates to: + /// **'How the hub checks incoming calls: the active validator, the tokens and their scope grants. Secrets stay in environment variables — only their names appear here.'** + String get authPolicyBlurb; + + /// No description provided for @authPolicyValidatorStatic. + /// + /// In en, this message translates to: + /// **'Validator: static token list'** + String get authPolicyValidatorStatic; + + /// No description provided for @authPolicyValidatorJwt. + /// + /// In en, this message translates to: + /// **'Validator: JWT (RS256) via an external identity provider'** + String get authPolicyValidatorJwt; + + /// No description provided for @authPolicyAnonymous. + /// + /// In en, this message translates to: + /// **'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 @authPolicyNeedsAdmin. + /// + /// In en, this message translates to: + /// **'This view needs a token with the admin scope. Store it above under “Hub authentication” and reload.'** + String get authPolicyNeedsAdmin; + + /// No description provided for @authPolicyHubTooOld. + /// + /// In en, this message translates to: + /// **'The connected hub does not know this view yet — it is older than Studio. Update the hub (chain update apply) and reload.'** + String get authPolicyHubTooOld; + + /// No description provided for @authPolicyRetry. + /// + /// In en, this message translates to: + /// **'Retry'** + String get authPolicyRetry; + + /// No description provided for @authPolicyReload. + /// + /// In en, this message translates to: + /// **'Reload tokens'** + String get authPolicyReload; + + /// No description provided for @authPolicyReloadDone. + /// + /// In en, this message translates to: + /// **'Reloaded — {n} tokens active.'** + String authPolicyReloadDone(int n); + + /// No description provided for @authPolicyEditHint. + /// + /// In en, this message translates to: + /// **'The policy is edited in ~/.chain/config.yaml (auth: section). After a change or token rotation, reload here — the hub applies it without a restart.'** + String get authPolicyEditHint; + + /// No description provided for @authPolicyEnvSet. + /// + /// In en, this message translates to: + /// **'Environment variable {env} is set'** + String authPolicyEnvSet(String env); + + /// No description provided for @authPolicyEnvMissing. + /// + /// In en, this message translates to: + /// **'Environment variable {env} is MISSING — the token is unusable'** + String authPolicyEnvMissing(String env); + + /// No description provided for @authPolicyRateLimit. + /// + /// In en, this message translates to: + /// **'{n}/min'** + String authPolicyRateLimit(int n); + + /// No description provided for @authPolicyJwtKeySource. + /// + /// In en, this message translates to: + /// **'Key source'** + String get authPolicyJwtKeySource; + + /// No description provided for @authPolicyJwtAudience. + /// + /// In en, this message translates to: + /// **'Audience (aud)'** + String get authPolicyJwtAudience; + + /// No description provided for @authPolicyJwtIssuer. + /// + /// In en, this message translates to: + /// **'Issuer (iss)'** + String get authPolicyJwtIssuer; + + /// No description provided for @authPolicyJwtScopeClaim. + /// + /// In en, this message translates to: + /// **'Scope claim'** + String get authPolicyJwtScopeClaim; + + /// No description provided for @authPolicyNotChecked. + /// + /// In en, this message translates to: + /// **'not checked'** + String get authPolicyNotChecked; + /// No description provided for @hubAuthTokenHeader. /// /// In en, this message translates to: @@ -4541,19 +4655,19 @@ abstract class AppLocalizations { /// No description provided for @addSourceIntro. /// /// In en, this message translates to: - /// **'`{capability}` is not in the public store. Point the hub at a `.fai` bundle URL or a local bundle path; the hub downloads, verifies (sha256 + signature) and installs it.'** + /// **'`{capability}` is not in the public store. Point the hub at a `.chain` bundle URL or a local bundle path; the hub downloads, verifies (sha256 + signature) and installs it.'** String addSourceIntro(String capability); /// No description provided for @addSourceField. /// /// In en, this message translates to: - /// **'URL or path to .fai bundle'** + /// **'URL or path to .chain bundle'** String get addSourceField; /// No description provided for @addSourceHint. /// /// In en, this message translates to: - /// **'https://git.flemming.ai/your-org/your-module/releases/download/v0.1.0/foo-0.1.0.fai'** + /// **'https://git.flemming.ai/your-org/your-module/releases/download/v0.1.0/foo-0.1.0.chain'** String get addSourceHint; /// No description provided for @addSourceHowItWorksTitle. @@ -4565,7 +4679,7 @@ abstract class AppLocalizations { /// No description provided for @addSourceHowItWorksBody. /// /// In en, this message translates to: - /// **'A module is a directory with a module.yaml + the WASM artifact. To share it: pack it (`chain pack `) and host the resulting `.fai` bundle anywhere (your own Forgejo / GitHub / S3). The hub installs by URL and verifies the signature against its trust store.\n\nDeveloping locally? Use the CLI — Studio cannot install from an unpacked directory (yet):'** + /// **'A module is a directory with a module.yaml + the WASM artifact. To share it: pack it (`chain pack `) and host the resulting `.chain` bundle anywhere (your own Forgejo / GitHub / S3). The hub installs by URL and verifies the signature against its trust store.\n\nDeveloping locally? Use the CLI — Studio cannot install from an unpacked directory (yet):'** String get addSourceHowItWorksBody; /// No description provided for @addSourceCliExample. diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index dba63ee..9eb86b2 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1455,7 +1455,7 @@ class AppLocalizationsDe extends AppLocalizations { @override String get registryCredentialsBlurb => - 'Token zum Herunterladen von .fai-Modulen aus einer Registry mit Anmeldepflicht (Forgejo, private GitHub-Repos). Gespeichert in ~/.chain/registry-token, Modus 0600. Die Umgebungsvariable CHAIN_REGISTRY_TOKEN hat weiterhin Vorrang, wenn gesetzt.'; + 'Token zum Herunterladen von .chain-Modulen aus einer Registry mit Anmeldepflicht (Forgejo, private GitHub-Repos). Gespeichert in ~/.chain/registry-token, Modus 0600. Die Umgebungsvariable CHAIN_REGISTRY_TOKEN hat weiterhin Vorrang, wenn gesetzt.'; @override String registryTokenStatusConfigured(int chars) { @@ -1492,6 +1492,78 @@ class AppLocalizationsDe extends AppLocalizations { return 'Speichern fehlgeschlagen: $error'; } + @override + String get authPolicyHeader => 'ZUGRIFFSSCHUTZ DES HUBS'; + + @override + String get authPolicyBlurb => + 'Wie der Hub eingehende Aufrufe prüft: aktives Prüfverfahren, Tokens und ihre Rechte (Scopes). Geheimnisse bleiben in Umgebungsvariablen — hier erscheinen nur deren Namen.'; + + @override + String get authPolicyValidatorStatic => + 'Prüfverfahren: statische Token-Liste'; + + @override + String get authPolicyValidatorJwt => + 'Prüfverfahren: JWT (RS256) über eine externe Identitätsstelle'; + + @override + 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 authPolicyNeedsAdmin => + 'Diese Ansicht braucht ein Token mit admin-Recht. Hinterlegen Sie es oben unter „Hub-Authentifizierung“ und laden Sie neu.'; + + @override + String get 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.'; + + @override + String get authPolicyRetry => 'Erneut versuchen'; + + @override + String get authPolicyReload => 'Tokens neu laden'; + + @override + String authPolicyReloadDone(int n) { + return 'Neu geladen — $n Token aktiv.'; + } + + @override + String get authPolicyEditHint => + 'Bearbeitet wird die Richtlinie in ~/.chain/config.yaml (Abschnitt auth:). Nach einer Änderung oder Token-Rotation hier neu laden — der Hub übernimmt sie ohne Neustart.'; + + @override + String authPolicyEnvSet(String env) { + return 'Umgebungsvariable $env ist gesetzt'; + } + + @override + String authPolicyEnvMissing(String env) { + return 'Umgebungsvariable $env FEHLT — das Token ist nicht nutzbar'; + } + + @override + String authPolicyRateLimit(int n) { + return '$n/min'; + } + + @override + String get authPolicyJwtKeySource => 'Schlüsselquelle'; + + @override + String get authPolicyJwtAudience => 'Audience (aud)'; + + @override + String get authPolicyJwtIssuer => 'Aussteller (iss)'; + + @override + String get authPolicyJwtScopeClaim => 'Scope-Claim'; + + @override + String get authPolicyNotChecked => 'wird nicht geprüft'; + @override String get hubAuthTokenHeader => 'HUB-AUTHENTIFIZIERUNG'; @@ -2648,22 +2720,22 @@ class AppLocalizationsDe extends AppLocalizations { @override String addSourceIntro(String capability) { - return '`$capability` ist nicht im öffentlichen Store. Zeig dem Hub eine `.fai`-Bundle-URL oder einen lokalen Bundle-Pfad — der Hub lädt es herunter, prüft (sha256 + Signatur) und installiert.'; + return '`$capability` ist nicht im öffentlichen Store. Zeig dem Hub eine `.chain`-Bundle-URL oder einen lokalen Bundle-Pfad — der Hub lädt es herunter, prüft (sha256 + Signatur) und installiert.'; } @override - String get addSourceField => 'URL oder Pfad zum .fai-Bundle'; + String get addSourceField => 'URL oder Pfad zum .chain-Bundle'; @override String get addSourceHint => - 'https://git.flemming.ai/deine-org/dein-modul/releases/download/v0.1.0/foo-0.1.0.fai'; + 'https://git.flemming.ai/deine-org/dein-modul/releases/download/v0.1.0/foo-0.1.0.chain'; @override String get addSourceHowItWorksTitle => 'Wie private Module funktionieren'; @override String get addSourceHowItWorksBody => - 'Ein Modul ist ein Verzeichnis mit module.yaml + WASM-Artefakt. Zum Teilen: `chain pack ` baut ein `.fai`-Bundle, das du beliebig hosten kannst (eigene Forgejo / GitHub / S3). Der Hub installiert per URL und prüft die Signatur gegen seinen Trust-Store.\n\nLokal entwickeln? Nimm das CLI — Studio installiert (noch) nicht aus einem unverpackten Verzeichnis:'; + 'Ein Modul ist ein Verzeichnis mit module.yaml + WASM-Artefakt. Zum Teilen: `chain pack ` baut ein `.chain`-Bundle, das du beliebig hosten kannst (eigene Forgejo / GitHub / S3). Der Hub installiert per URL und prüft die Signatur gegen seinen Trust-Store.\n\nLokal entwickeln? Nimm das CLI — Studio installiert (noch) nicht aus einem unverpackten Verzeichnis:'; @override String get addSourceCliExample => 'chain install --link /pfad/zum/modul'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index c137dd3..0ad4d2e 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1468,7 +1468,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get registryCredentialsBlurb => - 'Token used to download .fai modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.chain/registry-token, mode 0600. The CHAIN_REGISTRY_TOKEN env var still wins when set.'; + 'Token used to download .chain modules from a registry behind a signin wall (Forgejo, GitHub-private). Stored at ~/.chain/registry-token, mode 0600. The CHAIN_REGISTRY_TOKEN env var still wins when set.'; @override String registryTokenStatusConfigured(int chars) { @@ -1506,6 +1506,77 @@ class AppLocalizationsEn extends AppLocalizations { return 'Could not save: $error'; } + @override + String get authPolicyHeader => 'HUB ACCESS POLICY'; + + @override + String get authPolicyBlurb => + 'How the hub checks incoming calls: the active validator, the tokens and their scope grants. Secrets stay in environment variables — only their names appear here.'; + + @override + String get authPolicyValidatorStatic => 'Validator: static token list'; + + @override + String get authPolicyValidatorJwt => + 'Validator: JWT (RS256) via an external identity provider'; + + @override + 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 authPolicyNeedsAdmin => + 'This view needs a token with the admin scope. Store it above under “Hub authentication” and reload.'; + + @override + String get authPolicyHubTooOld => + 'The connected hub does not know this view yet — it is older than Studio. Update the hub (chain update apply) and reload.'; + + @override + String get authPolicyRetry => 'Retry'; + + @override + String get authPolicyReload => 'Reload tokens'; + + @override + String authPolicyReloadDone(int n) { + return 'Reloaded — $n tokens active.'; + } + + @override + String get authPolicyEditHint => + 'The policy is edited in ~/.chain/config.yaml (auth: section). After a change or token rotation, reload here — the hub applies it without a restart.'; + + @override + String authPolicyEnvSet(String env) { + return 'Environment variable $env is set'; + } + + @override + String authPolicyEnvMissing(String env) { + return 'Environment variable $env is MISSING — the token is unusable'; + } + + @override + String authPolicyRateLimit(int n) { + return '$n/min'; + } + + @override + String get authPolicyJwtKeySource => 'Key source'; + + @override + String get authPolicyJwtAudience => 'Audience (aud)'; + + @override + String get authPolicyJwtIssuer => 'Issuer (iss)'; + + @override + String get authPolicyJwtScopeClaim => 'Scope claim'; + + @override + String get authPolicyNotChecked => 'not checked'; + @override String get hubAuthTokenHeader => 'HUB AUTHENTICATION'; @@ -2651,22 +2722,22 @@ class AppLocalizationsEn extends AppLocalizations { @override String addSourceIntro(String capability) { - return '`$capability` is not in the public store. Point the hub at a `.fai` bundle URL or a local bundle path; the hub downloads, verifies (sha256 + signature) and installs it.'; + return '`$capability` is not in the public store. Point the hub at a `.chain` bundle URL or a local bundle path; the hub downloads, verifies (sha256 + signature) and installs it.'; } @override - String get addSourceField => 'URL or path to .fai bundle'; + String get addSourceField => 'URL or path to .chain bundle'; @override String get addSourceHint => - 'https://git.flemming.ai/your-org/your-module/releases/download/v0.1.0/foo-0.1.0.fai'; + 'https://git.flemming.ai/your-org/your-module/releases/download/v0.1.0/foo-0.1.0.chain'; @override String get addSourceHowItWorksTitle => 'How private modules work'; @override String get addSourceHowItWorksBody => - 'A module is a directory with a module.yaml + the WASM artifact. To share it: pack it (`chain pack `) and host the resulting `.fai` bundle anywhere (your own Forgejo / GitHub / S3). The hub installs by URL and verifies the signature against its trust store.\n\nDeveloping locally? Use the CLI — Studio cannot install from an unpacked directory (yet):'; + 'A module is a directory with a module.yaml + the WASM artifact. To share it: pack it (`chain pack `) and host the resulting `.chain` bundle anywhere (your own Forgejo / GitHub / S3). The hub installs by URL and verifies the signature against its trust store.\n\nDeveloping locally? Use the CLI — Studio cannot install from an unpacked directory (yet):'; @override String get addSourceCliExample => 'chain install --link /path/to/module'; diff --git a/lib/widgets/chain_settings_dialog.dart b/lib/widgets/chain_settings_dialog.dart index 0dff4d1..3a28ae4 100644 --- a/lib/widgets/chain_settings_dialog.dart +++ b/lib/widgets/chain_settings_dialog.dart @@ -17,6 +17,7 @@ import '../theme/tokens.dart'; import 'chain_error_box.dart'; import 'chain_pill.dart'; import 'chain_system_ai_editor.dart'; +import 'hub_auth_policy_panel.dart'; import 'theme_picker_grid.dart'; class ChainSettingsDialog extends StatefulWidget { @@ -648,6 +649,8 @@ class _FaiSettingsDialogState extends State { onSave: _saveHubAuthToken, onClear: _clearHubAuthToken, ), + const SizedBox(height: ChainSpace.xl), + const HubAuthPolicyPanel(), ]; } @@ -1820,7 +1823,7 @@ class _MaintenancePanelState extends State<_MaintenancePanel> { /// Credentials panel for the operator's registry auth token. /// The hub reads the same token via `~/.chain/registry-token` /// (or the `CHAIN_REGISTRY_TOKEN` env var, which still wins) -/// when downloading `.fai` bundles from a registry behind a +/// when downloading `.chain` bundles from a registry behind a /// signin wall — Forgejo with REQUIRE_SIGNIN_VIEW=true, /// GitHub-private releases, etc. /// diff --git a/lib/widgets/hub_auth_policy_panel.dart b/lib/widgets/hub_auth_policy_panel.dart new file mode 100644 index 0000000..a1635b6 --- /dev/null +++ b/lib/widgets/hub_auth_policy_panel.dart @@ -0,0 +1,390 @@ +// Read-only view of the hub's authentication policy (T4/T5): +// which token validator is active, whether anonymous calls are +// accepted, and the configured tokens with their scope grants — +// surfaced in Settings → Security so security administration is +// inspectable without opening config.yaml. Editing stays in the +// operator config on purpose (secrets live in env vars, the file +// carries only names); the panel says so and offers the live +// reload that applies a rotation without a daemon restart. + +import 'package:flutter/material.dart'; + +import '../data/hub.dart'; +import '../l10n/app_localizations.dart'; +import '../theme/tokens.dart'; +import 'chain_error_box.dart'; + +class HubAuthPolicyPanel extends StatefulWidget { + /// Test seam: replaces the live [HubService.authStatus] call. + @visibleForTesting + final Future Function()? loader; + + /// Test seam: replaces the live [HubService.reloadHubAuth] call. + @visibleForTesting + final Future Function()? reloader; + + const HubAuthPolicyPanel({super.key, this.loader, this.reloader}); + + @override + State createState() => _HubAuthPolicyPanelState(); +} + +class _HubAuthPolicyPanelState extends State { + HubAuthPolicy? _policy; + Object? _error; + bool _loading = true; + bool _reloading = false; + String? _reloadNote; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + }); + try { + final load = widget.loader ?? HubService.instance.authStatus; + final p = await load(); + if (!mounted) return; + setState(() { + _policy = p; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e; + _loading = false; + }); + } + } + + Future _reload() async { + final l = AppLocalizations.of(context)!; + setState(() { + _reloading = true; + _reloadNote = null; + }); + try { + final reload = widget.reloader ?? HubService.instance.reloadHubAuth; + final n = await reload(); + if (!mounted) return; + setState(() { + _reloading = false; + _reloadNote = l.authPolicyReloadDone(n); + }); + await _load(); + } catch (e) { + if (!mounted) return; + setState(() { + _reloading = false; + _error = e; + }); + } + } + + /// True when the failure is the hub refusing the caller — + /// either no/invalid token (UNAUTHENTICATED) or a token + /// without the admin scope (PERMISSION_DENIED). Both get the + /// same plain-language fix: store an admin token above. + bool _isPermissionDenied(Object e) { + final s = e.toString(); + return s.contains('PERMISSION_DENIED') || + s.contains('code: 7') || + s.contains('UNAUTHENTICATED') || + s.contains('code: 16'); + } + + /// True when the hub predates the AuthStatus RPC (skew: Studio + /// newer than the hub) — gets an update hint, not a raw error. + bool _isUnimplemented(Object e) { + final s = e.toString(); + return s.contains('UNIMPLEMENTED') || s.contains('code: 12'); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l.authPolicyHeader, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + letterSpacing: 0.6, + fontSize: 10, + ), + ), + const SizedBox(height: 2), + Text( + l.authPolicyBlurb, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: ChainSpace.sm), + if (_loading) + const Padding( + padding: EdgeInsets.symmetric(vertical: ChainSpace.md), + child: Center( + child: SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ) + else if (_error != null) ...[ + if (_isPermissionDenied(_error!)) + _hintRow(theme, Icons.lock_outline, l.authPolicyNeedsAdmin) + else if (_isUnimplemented(_error!)) + _hintRow(theme, Icons.update, l.authPolicyHubTooOld) + else + ChainErrorBox(error: _error!, isError: true, maxHeight: 160), + const SizedBox(height: ChainSpace.xs), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh, size: 16), + label: Text(l.authPolicyRetry), + ), + ), + ] else if (_policy != null) + ..._policyView(theme, l, _policy!), + ], + ); + } + + List _policyView(ThemeData theme, AppLocalizations l, HubAuthPolicy p) { + final isJwt = p.validator == 'jwt-rs256'; + return [ + Row( + children: [ + Icon( + Icons.verified_user_outlined, + size: 16, + color: theme.colorScheme.primary, + ), + const SizedBox(width: ChainSpace.xs), + Expanded( + child: Text( + isJwt ? l.authPolicyValidatorJwt : l.authPolicyValidatorStatic, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + if (p.anonymousAllowed) ...[ + const SizedBox(height: ChainSpace.xs), + _hintRow( + theme, + Icons.warning_amber_outlined, + l.authPolicyAnonymous, + color: theme.colorScheme.tertiary, + ), + ], + if (p.tokens.isNotEmpty) ...[ + const SizedBox(height: ChainSpace.sm), + for (final t in p.tokens) _tokenRow(theme, l, t), + ], + if (isJwt && p.jwt != null) ...[ + const SizedBox(height: ChainSpace.sm), + _kvRow(theme, l.authPolicyJwtKeySource, p.jwt!.keySource), + _kvRow( + theme, + l.authPolicyJwtAudience, + p.jwt!.audience.isEmpty ? l.authPolicyNotChecked : p.jwt!.audience, + ), + _kvRow( + theme, + l.authPolicyJwtIssuer, + p.jwt!.issuer.isEmpty ? l.authPolicyNotChecked : p.jwt!.issuer, + ), + _kvRow(theme, l.authPolicyJwtScopeClaim, p.jwt!.scopeClaim), + ], + const SizedBox(height: ChainSpace.sm), + Text( + l.authPolicyEditHint, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: ChainSpace.xs), + Row( + children: [ + OutlinedButton.icon( + onPressed: _reloading ? null : _reload, + icon: _reloading + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh, size: 16), + label: Text(l.authPolicyReload), + ), + if (_reloadNote != null) ...[ + const SizedBox(width: ChainSpace.sm), + Flexible( + child: Text( + _reloadNote!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ), + ], + ], + ), + ]; + } + + Widget _tokenRow(ThemeData theme, AppLocalizations l, HubAuthTokenEntry t) { + return Padding( + padding: const EdgeInsets.only(bottom: ChainSpace.sm), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: ChainSpace.sm, + vertical: ChainSpace.xs, + ), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(ChainRadius.sm), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + t.name, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + if (t.rateLimitPerMinute > 0) + Text( + l.authPolicyRateLimit(t.rateLimitPerMinute), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 4), + Wrap( + spacing: 4, + runSpacing: 4, + children: [ + for (final s in t.scopes) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 1, + ), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(ChainRadius.sm), + ), + child: Text( + s, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + t.envSet ? Icons.check_circle : Icons.error_outline, + size: 13, + color: t.envSet + ? theme.colorScheme.primary + : theme.colorScheme.error, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + t.envSet + ? l.authPolicyEnvSet(t.tokenEnv) + : l.authPolicyEnvMissing(t.tokenEnv), + style: theme.textTheme.bodySmall?.copyWith( + color: t.envSet + ? theme.colorScheme.onSurfaceVariant + : theme.colorScheme.error, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _kvRow(ThemeData theme, String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 140, + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: SelectableText( + value, + style: theme.textTheme.bodySmall, + ), + ), + ], + ), + ); + } + + Widget _hintRow( + ThemeData theme, + IconData icon, + String text, { + Color? color, + }) { + final c = color ?? theme.colorScheme.onSurfaceVariant; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: c), + const SizedBox(width: 6), + Expanded( + child: Text( + text, + style: theme.textTheme.bodySmall?.copyWith(color: c), + ), + ), + ], + ); + } +} diff --git a/test/chain_log_test.dart b/test/chain_log_test.dart index 4104b0a..a3a2aaa 100644 --- a/test/chain_log_test.dart +++ b/test/chain_log_test.dart @@ -1,5 +1,5 @@ // Unit tests for ChainLog — the central error log that -// `~/.fai/logs/studio-errors.log` is the operator-visible +// `~/.chain/logs/studio-errors.log` is the operator-visible // surface of. The tests redirect the singleton at a temp file // via `ChainLog.testPathOverride` so the real log under HOME is // never touched. diff --git a/test/hub_auth_policy_panel_test.dart b/test/hub_auth_policy_panel_test.dart new file mode 100644 index 0000000..d909421 --- /dev/null +++ b/test/hub_auth_policy_panel_test.dart @@ -0,0 +1,159 @@ +// Settings → Security → hub access policy panel. Verifies the +// read-only rendering of the AuthStatus snapshot (validator, +// anonymous warning, token entries with scope grants and env-var +// state, JWT parameters) plus the live-reload affordance and the +// admin-scope refusal story — all through the loader test seams, +// no hub required. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/widgets/hub_auth_policy_panel.dart'; + +Widget _host(HubAuthPolicyPanel panel) => MaterialApp( + locale: const Locale('de'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold(body: SingleChildScrollView(child: panel)), +); + +HubAuthPolicy _staticPolicy() => const HubAuthPolicy( + validator: 'static', + anonymousAllowed: false, + tokens: [ + HubAuthTokenEntry( + name: 'digiscout-prod', + tokenEnv: 'CHAIN_TOKEN_DIGISCOUT', + envSet: true, + scopes: ['read', 'execute:llm.*'], + rateLimitPerMinute: 120, + ), + HubAuthTokenEntry( + name: 'ci-reader', + tokenEnv: 'CHAIN_TOKEN_CI', + envSet: false, + scopes: ['read'], + rateLimitPerMinute: 0, + ), + ], +); + +void main() { + testWidgets('static policy renders tokens, grants and env state', ( + tester, + ) async { + await tester.pumpWidget( + _host(HubAuthPolicyPanel(loader: () async => _staticPolicy())), + ); + await tester.pumpAndSettle(); + + expect( + find.text('Prüfverfahren: statische Token-Liste'), + findsOneWidget, + ); + expect(find.text('digiscout-prod'), findsOneWidget); + // Fine-grained T5 grant rendered verbatim as a chip. + expect(find.text('execute:llm.*'), findsOneWidget); + expect(find.text('120/min'), findsOneWidget); + expect( + find.textContaining('CHAIN_TOKEN_DIGISCOUT ist gesetzt'), + findsOneWidget, + ); + // The rotation footgun is called out, not hidden. + expect(find.textContaining('CHAIN_TOKEN_CI FEHLT'), findsOneWidget); + // No anonymous warning when tokens exist. + expect(find.textContaining('anonyme Aufrufe'), findsNothing); + }); + + testWidgets('anonymous hub shows the plain-language warning', ( + tester, + ) async { + await tester.pumpWidget( + _host( + HubAuthPolicyPanel( + loader: () async => const HubAuthPolicy( + validator: 'static', + anonymousAllowed: true, + tokens: [], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('anonyme Aufrufe'), findsOneWidget); + }); + + testWidgets('jwt policy renders parameters with not-checked fallback', ( + tester, + ) async { + await tester.pumpWidget( + _host( + HubAuthPolicyPanel( + loader: () async => const HubAuthPolicy( + validator: 'jwt-rs256', + anonymousAllowed: false, + tokens: [], + jwt: HubJwtValidatorInfo( + keySource: '/etc/chain/idp.pem', + audience: 'chain-hub', + issuer: '', + scopeClaim: 'scope', + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.textContaining('JWT (RS256)'), + findsOneWidget, + ); + expect(find.text('/etc/chain/idp.pem'), findsOneWidget); + expect(find.text('chain-hub'), findsOneWidget); + // Empty issuer → honest "not checked", not an empty cell. + expect(find.text('wird nicht geprüft'), findsOneWidget); + }); + + testWidgets('reload button reports the new token count', (tester) async { + var reloads = 0; + await tester.pumpWidget( + _host( + HubAuthPolicyPanel( + loader: () async => _staticPolicy(), + reloader: () async { + reloads++; + return 2; + }, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Tokens neu laden')); + await tester.pumpAndSettle(); + + expect(reloads, 1); + expect(find.text('Neu geladen — 2 Token aktiv.'), findsOneWidget); + }); + + testWidgets('permission denied maps to the admin-scope story', ( + tester, + ) async { + await tester.pumpWidget( + _host( + HubAuthPolicyPanel( + loader: () async => + throw Exception('gRPC Error (code: 7, codeName: PERMISSION_DENIED)'), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.textContaining('admin-Recht'), findsOneWidget); + expect(find.text('Erneut versuchen'), findsOneWidget); + }); +} diff --git a/test/integration/hub_fixture.dart b/test/integration/hub_fixture.dart index 4ca24de..1c1471a 100644 --- a/test/integration/hub_fixture.dart +++ b/test/integration/hub_fixture.dart @@ -1,6 +1,6 @@ // Hub fixture for integration-level tests. // -// Spawns a real `fai serve` subprocess on a free port in a +// Spawns a real `chain serve` subprocess on a free port in a // temporary directory, waits for its gRPC endpoint to answer // `Healthy`, and exposes the port + a teardown so test files // can talk to a clean Hub without setting up an isolate-side @@ -8,13 +8,13 @@ // // Cost / scope notes: // -// * The `fai` binary must be on the operator's PATH or at -// `target/release/fai` relative to the platform repo. We +// * The `chain` binary must be on the operator's PATH or at +// `target/release/chain` relative to the platform repo. We // resolve those two paths in order; if neither exists, the // fixture skips the suite with a clear message rather than // failing — these tests are opt-in. // * The hub is started with `--quiet` (no log spam) against a -// temp dir, so the operator's real `~/.fai/` stays untouched. +// temp dir, so the operator's real `~/.chain/` stays untouched. // * Cleanup runs `process.kill(SIGTERM)` then `process.kill()` // after a grace period. The temp dir is removed. // * Each fixture is independent — call `HubFixture.start()` in @@ -59,6 +59,13 @@ class HubFixture { /// `markSkipped` instead of failing when the binary isn't /// present — that's the desired behaviour in CI environments /// that don't build the binary first. + /// + /// [configYaml], when given, is written to `/config.yaml` + /// and handed to the hub via `$CHAIN_CONFIG` — tests can exercise + /// operator policy (e.g. `auth.tokens`) without touching the real + /// `~/.chain/config.yaml`. Token *values* go through + /// [extraEnvironment]; pass the matching [authToken] so the + /// readiness poll (and [client]) authenticate against the hub. static Future start({ // A cold hub spends its first 30s building the curated- // model database + initialising SQLite. 60s gives that @@ -66,6 +73,9 @@ class HubFixture { // a slow one. Duration readyTimeout = const Duration(seconds: 60), bool skipIfBinaryMissing = true, + String? configYaml, + Map extraEnvironment = const {}, + String? authToken, }) async { final binary = await _resolveBinary(); if (binary == null) { @@ -84,10 +94,16 @@ class HubFixture { final port = await _pickFreePort(); final addr = '127.0.0.1:$port'; + String? configPath; + if (configYaml != null) { + configPath = '${tempDir.path}/config.yaml'; + File(configPath).writeAsStringSync(configYaml); + } + // Run as a child process. `serve --bind` lets us avoid the // default 50051 (and any local daemon the developer happens // to be running). CHAIN_DATA_DIR keeps the temp footprint - // contained — no state leaks into ~/.fai. + // contained — no state leaks into ~/.chain. final process = await Process.start( binary, ['serve', '--bind', addr], @@ -95,6 +111,8 @@ class HubFixture { ...Platform.environment, 'CHAIN_DATA_DIR': tempDir.path, 'CHAIN_MODULES_DIR': '${tempDir.path}/modules', + 'CHAIN_CONFIG': ?configPath, + ...extraEnvironment, // Suppress info logs without a CLI flag — the binary // respects RUST_LOG for tracing-subscriber. 'RUST_LOG': 'warn', @@ -111,6 +129,7 @@ class HubFixture { // a hung hub is a test failure, not a hang. final client = HubClient( endpoint: HubEndpoint(host: '127.0.0.1', port: port), + authToken: authToken, ); final deadline = DateTime.now().add(readyTimeout); while (DateTime.now().isBefore(deadline)) { @@ -136,7 +155,7 @@ class HubFixture { await tempDir.delete(recursive: true); throw StateError( 'Hub did not become healthy within $readyTimeout. ' - 'Check `fai serve` works manually with CHAIN_DATA_DIR=$tempDir.', + 'Check `chain serve` works manually with CHAIN_DATA_DIR=$tempDir.', ); } diff --git a/tools/today/README.md b/tools/today/README.md index 61cb78f..b1cf349 100644 --- a/tools/today/README.md +++ b/tools/today/README.md @@ -9,12 +9,12 @@ See `../../docs/today-pipeline.md` for the full design rationale. # 1. Generate today's proposals (calls the local Ollama by default). ./propose.sh -# 2. Skim them — they sit under ~/.fai/today/proposals// -ls ~/.fai/today/proposals/ +# 2. Skim them — they sit under ~/.chain/today/proposals// +ls ~/.chain/today/proposals/ -# 3. Accept one. The chosen file becomes ~/.fai/today/active.yaml, +# 3. Accept one. The chosen file becomes ~/.chain/today/active.yaml, # which Studio reads at startup. -./accept.sh ~/.fai/today/proposals/2026-05-08/candidate-2.yaml +./accept.sh ~/.chain/today/proposals/2026-05-08/candidate-2.yaml # 4. Restart Studio (or let the next launch pick it up). ``` @@ -65,8 +65,8 @@ or `generate`; the script auto-detects from the URL path. installed Ch∆In monorepos. Never your audit log without explicit op-in. - Calls only the System-AI you've already configured for Studio. The same privacy mode you set there applies here. -- Writes only into `~/.fai/today/`. Nothing in `~/.fai/data/` or - `~/.fai/config.yaml` is touched. +- Writes only into `~/.chain/today/`. Nothing in `~/.chain/data/` or + `~/.chain/config.yaml` is touched. - Studio loads `active.yaml` at startup; if the file is missing or fails schema validation, the compiled-in fallback story renders. KRITIS fresh installs see the fallback until and unless an operator accepts a diff --git a/tools/today/accept.sh b/tools/today/accept.sh index 2b81016..5a38b1c 100755 --- a/tools/today/accept.sh +++ b/tools/today/accept.sh @@ -2,7 +2,7 @@ # tools/today/accept.sh # # Validates the chosen candidate, then atomically swaps it in as -# ~/.fai/today/active.yaml. Studio reads that file at startup. +# ~/.chain/today/active.yaml. Studio reads that file at startup. set -euo pipefail if [ "${1:-}" = "" ]; then @@ -45,7 +45,7 @@ fi # Atomic move via copy-then-rename so Studio never reads a torn # half-written file even if it polls during the swap. -dst="$HOME/.fai/today/active.yaml" +dst="$HOME/.chain/today/active.yaml" mkdir -p "$(dirname "$dst")" tmp="$(mktemp "${dst}.XXXXXX")" cp "$src" "$tmp"