feat(settings): hub auth-policy panel — T4/T5 security parity in the GUI
Some checks failed
Security / Security check (push) Failing after 1s

Settings → Security now shows the hub's effective auth policy via the
new read-only AuthStatus RPC: active token validator (static / jwt-rs256
with issuer, audience, JWKS source), anonymous-access warning, per-token
cards with scope grants, env-var presence and rate limits, plus a
localized admin-denied story for non-admin tokens. Live-reloads on
endpoint change.

Also fixes a batch of fai→chain rename leftovers this panel's
verification uncovered: hub_auth_token.dart and registry_token.dart
read/wrote ~/.fai/ while the hub reads ~/.chain/ (stored registry
tokens never reached the hub), today_story_loader + tools/today used
~/.fai/today, chain_log legacy ~/.fai/logs migration removed per the
no-legacy-recognisers decision, and UI strings still advertised the
retired .fai bundle extension.

Includes 5 widget tests for the panel, an integration-test screenshot
harness (auth_policy_shots_test.dart, guide-shots style), and DE+EN
l10n. flutter analyze clean, 58 tests green.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-15 03:19:33 +02:00
parent c6da5025ce
commit efaa089454
18 changed files with 1208 additions and 74 deletions

View file

@ -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

View file

@ -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<HubAuthPolicy> 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<String>.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<int> 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<HubAuthTokenEntry> 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<String> 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;

View file

@ -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<bool> isConfigured() async {

View file

@ -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<bool> isConfigured() async {

View file

@ -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`

View file

@ -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 <verzeichnis>` 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 <verzeichnis>` 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",

View file

@ -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 <dir>`) 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 <dir>`) 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",

View file

@ -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 <dir>`) 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 <dir>`) 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.

View file

@ -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 <verzeichnis>` 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 <verzeichnis>` 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';

View file

@ -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 <dir>`) 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 <dir>`) 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';

View file

@ -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<ChainSettingsDialog> {
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.
///

View file

@ -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<HubAuthPolicy> Function()? loader;
/// Test seam: replaces the live [HubService.reloadHubAuth] call.
@visibleForTesting
final Future<int> Function()? reloader;
const HubAuthPolicyPanel({super.key, this.loader, this.reloader});
@override
State<HubAuthPolicyPanel> createState() => _HubAuthPolicyPanelState();
}
class _HubAuthPolicyPanelState extends State<HubAuthPolicyPanel> {
HubAuthPolicy? _policy;
Object? _error;
bool _loading = true;
bool _reloading = false;
String? _reloadNote;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<void> _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<Widget> _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),
),
),
],
);
}
}