feat(settings): hub auth-policy panel — T4/T5 security parity in the GUI
Some checks failed
Security / Security check (push) Failing after 1s
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:
parent
c6da5025ce
commit
efaa089454
18 changed files with 1208 additions and 74 deletions
187
integration_test/auth_policy_shots_test.dart
Normal file
187
integration_test/auth_policy_shots_test.dart
Normal file
|
|
@ -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<void> _pumpFrames(WidgetTester tester, [int frames = 20]) async {
|
||||||
|
for (var i = 0; i < frames; i++) {
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -51,31 +51,7 @@ class ChainLog {
|
||||||
Platform.environment['HOME'] ??
|
Platform.environment['HOME'] ??
|
||||||
Platform.environment['USERPROFILE'] ??
|
Platform.environment['USERPROFILE'] ??
|
||||||
'.';
|
'.';
|
||||||
final path = p.join(home, '.chain', 'logs', 'studio-errors.log');
|
return 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.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Absolute path of the studio-errors log. Public so the
|
/// Absolute path of the studio-errors log. Public so the
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// Send a one-shot prompt to the System AI. Returns the
|
||||||
/// answer + latency on success or an [AskAiResult] with
|
/// answer + latency on success or an [AskAiResult] with
|
||||||
/// `errorKind` set on failure (never throws). Studio maps
|
/// `errorKind` set on failure (never throws). Studio maps
|
||||||
|
|
@ -794,7 +830,7 @@ class HubService {
|
||||||
.toList();
|
.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;
|
/// Returns the installed module's name + version on success;
|
||||||
/// throws on hub error (signature mismatch, sha256 fail, etc.).
|
/// throws on hub error (signature mismatch, sha256 fail, etc.).
|
||||||
Future<({String name, String version})> installModule({
|
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 {
|
class SystemAiStatus {
|
||||||
final bool enabled;
|
final bool enabled;
|
||||||
final String provider;
|
final String provider;
|
||||||
|
|
|
||||||
|
|
@ -14,17 +14,17 @@ import 'package:path/path.dart' as p;
|
||||||
/// anonymous mode; the hub then either accepts or rejects
|
/// anonymous mode; the hub then either accepts or rejects
|
||||||
/// based on its own `auth.tokens:` config.
|
/// based on its own `auth.tokens:` config.
|
||||||
class HubAuthToken {
|
class HubAuthToken {
|
||||||
static String _faiHome() {
|
static String _chainHome() {
|
||||||
final home =
|
final home =
|
||||||
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
||||||
if (home == null || home.isEmpty) {
|
if (home == null || home.isEmpty) {
|
||||||
throw StateError('Cannot resolve home directory (no HOME / USERPROFILE)');
|
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.
|
/// 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.
|
/// True iff the file exists with a non-empty trimmed body.
|
||||||
static Future<bool> isConfigured() async {
|
static Future<bool> isConfigured() async {
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,17 @@ import 'package:path/path.dart' as p;
|
||||||
/// Studio writes the file directly so a fresh install never
|
/// Studio writes the file directly so a fresh install never
|
||||||
/// requires the operator to fiddle with shell env vars.
|
/// requires the operator to fiddle with shell env vars.
|
||||||
class RegistryToken {
|
class RegistryToken {
|
||||||
static String _faiHome() {
|
static String _chainHome() {
|
||||||
final home =
|
final home =
|
||||||
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
|
||||||
if (home == null || home.isEmpty) {
|
if (home == null || home.isEmpty) {
|
||||||
throw StateError('Cannot resolve home directory (no HOME / USERPROFILE)');
|
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.
|
/// 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.
|
/// True iff the file exists with a non-empty trimmed body.
|
||||||
static Future<bool> isConfigured() async {
|
static Future<bool> isConfigured() async {
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ class TodayStoryLoader {
|
||||||
Platform.environment['HOME'] ??
|
Platform.environment['HOME'] ??
|
||||||
Platform.environment['USERPROFILE'] ??
|
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`
|
/// Reads and validates the active story. Returns `fallback`
|
||||||
|
|
|
||||||
|
|
@ -804,7 +804,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"registryCredentialsHeader": "REGISTRY-ZUGANGSDATEN",
|
"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": "Konfiguriert ({chars} Zeichen)",
|
||||||
"@registryTokenStatusConfigured": {
|
"@registryTokenStatusConfigured": {
|
||||||
"placeholders": {
|
"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",
|
"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.",
|
"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)",
|
"hubAuthTokenStatusConfigured": "Eingerichtet ({chars} Zeichen)",
|
||||||
|
|
@ -1554,16 +1577,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"addSourceTitle": "Modul-Quelle hinzufügen",
|
"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": {
|
"@addSourceIntro": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"capability": {"type": "String"}
|
"capability": {"type": "String"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"addSourceField": "URL oder Pfad zum .fai-Bundle",
|
"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.fai",
|
"addSourceHint": "https://git.flemming.ai/deine-org/dein-modul/releases/download/v0.1.0/foo-0.1.0.chain",
|
||||||
"addSourceHowItWorksTitle": "Wie private Module funktionieren",
|
"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",
|
"addSourceCliExample": "chain install --link /pfad/zum/modul",
|
||||||
"addSourceInstallButton": "Installieren",
|
"addSourceInstallButton": "Installieren",
|
||||||
"addSourceCancel": "Abbrechen",
|
"addSourceCancel": "Abbrechen",
|
||||||
|
|
|
||||||
|
|
@ -822,7 +822,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"registryCredentialsHeader": "REGISTRY CREDENTIALS",
|
"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": "Configured ({chars} chars)",
|
||||||
"@registryTokenStatusConfigured": {
|
"@registryTokenStatusConfigured": {
|
||||||
"placeholders": {
|
"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",
|
"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.",
|
"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)",
|
"hubAuthTokenStatusConfigured": "Configured ({chars} chars)",
|
||||||
|
|
@ -1578,16 +1601,16 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"addSourceTitle": "Add module source",
|
"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": {
|
"@addSourceIntro": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
"capability": {"type": "String"}
|
"capability": {"type": "String"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"addSourceField": "URL or path to .fai bundle",
|
"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.fai",
|
"addSourceHint": "https://git.flemming.ai/your-org/your-module/releases/download/v0.1.0/foo-0.1.0.chain",
|
||||||
"addSourceHowItWorksTitle": "How private modules work",
|
"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",
|
"addSourceCliExample": "chain install --link /path/to/module",
|
||||||
"addSourceInstallButton": "Install",
|
"addSourceInstallButton": "Install",
|
||||||
"addSourceCancel": "Cancel",
|
"addSourceCancel": "Cancel",
|
||||||
|
|
|
||||||
|
|
@ -2555,7 +2555,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @registryCredentialsBlurb.
|
/// No description provided for @registryCredentialsBlurb.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// 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;
|
String get registryCredentialsBlurb;
|
||||||
|
|
||||||
/// No description provided for @registryTokenStatusConfigured.
|
/// No description provided for @registryTokenStatusConfigured.
|
||||||
|
|
@ -2618,6 +2618,120 @@ abstract class AppLocalizations {
|
||||||
/// **'Could not save: {error}'**
|
/// **'Could not save: {error}'**
|
||||||
String registryTokenSaveFailedToast(String 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.
|
/// No description provided for @hubAuthTokenHeader.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|
@ -4541,19 +4655,19 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @addSourceIntro.
|
/// No description provided for @addSourceIntro.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// 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);
|
String addSourceIntro(String capability);
|
||||||
|
|
||||||
/// No description provided for @addSourceField.
|
/// No description provided for @addSourceField.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'URL or path to .fai bundle'**
|
/// **'URL or path to .chain bundle'**
|
||||||
String get addSourceField;
|
String get addSourceField;
|
||||||
|
|
||||||
/// No description provided for @addSourceHint.
|
/// No description provided for @addSourceHint.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// 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;
|
String get addSourceHint;
|
||||||
|
|
||||||
/// No description provided for @addSourceHowItWorksTitle.
|
/// No description provided for @addSourceHowItWorksTitle.
|
||||||
|
|
@ -4565,7 +4679,7 @@ abstract class AppLocalizations {
|
||||||
/// No description provided for @addSourceHowItWorksBody.
|
/// No description provided for @addSourceHowItWorksBody.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// 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;
|
String get addSourceHowItWorksBody;
|
||||||
|
|
||||||
/// No description provided for @addSourceCliExample.
|
/// No description provided for @addSourceCliExample.
|
||||||
|
|
|
||||||
|
|
@ -1455,7 +1455,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get registryCredentialsBlurb =>
|
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
|
@override
|
||||||
String registryTokenStatusConfigured(int chars) {
|
String registryTokenStatusConfigured(int chars) {
|
||||||
|
|
@ -1492,6 +1492,78 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
return 'Speichern fehlgeschlagen: $error';
|
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
|
@override
|
||||||
String get hubAuthTokenHeader => 'HUB-AUTHENTIFIZIERUNG';
|
String get hubAuthTokenHeader => 'HUB-AUTHENTIFIZIERUNG';
|
||||||
|
|
||||||
|
|
@ -2648,22 +2720,22 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String addSourceIntro(String capability) {
|
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
|
@override
|
||||||
String get addSourceField => 'URL oder Pfad zum .fai-Bundle';
|
String get addSourceField => 'URL oder Pfad zum .chain-Bundle';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get addSourceHint =>
|
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
|
@override
|
||||||
String get addSourceHowItWorksTitle => 'Wie private Module funktionieren';
|
String get addSourceHowItWorksTitle => 'Wie private Module funktionieren';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get addSourceHowItWorksBody =>
|
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
|
@override
|
||||||
String get addSourceCliExample => 'chain install --link /pfad/zum/modul';
|
String get addSourceCliExample => 'chain install --link /pfad/zum/modul';
|
||||||
|
|
|
||||||
|
|
@ -1468,7 +1468,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get registryCredentialsBlurb =>
|
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
|
@override
|
||||||
String registryTokenStatusConfigured(int chars) {
|
String registryTokenStatusConfigured(int chars) {
|
||||||
|
|
@ -1506,6 +1506,77 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
return 'Could not save: $error';
|
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
|
@override
|
||||||
String get hubAuthTokenHeader => 'HUB AUTHENTICATION';
|
String get hubAuthTokenHeader => 'HUB AUTHENTICATION';
|
||||||
|
|
||||||
|
|
@ -2651,22 +2722,22 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String addSourceIntro(String capability) {
|
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
|
@override
|
||||||
String get addSourceField => 'URL or path to .fai bundle';
|
String get addSourceField => 'URL or path to .chain bundle';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get addSourceHint =>
|
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
|
@override
|
||||||
String get addSourceHowItWorksTitle => 'How private modules work';
|
String get addSourceHowItWorksTitle => 'How private modules work';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get addSourceHowItWorksBody =>
|
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
|
@override
|
||||||
String get addSourceCliExample => 'chain install --link /path/to/module';
|
String get addSourceCliExample => 'chain install --link /path/to/module';
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import '../theme/tokens.dart';
|
||||||
import 'chain_error_box.dart';
|
import 'chain_error_box.dart';
|
||||||
import 'chain_pill.dart';
|
import 'chain_pill.dart';
|
||||||
import 'chain_system_ai_editor.dart';
|
import 'chain_system_ai_editor.dart';
|
||||||
|
import 'hub_auth_policy_panel.dart';
|
||||||
import 'theme_picker_grid.dart';
|
import 'theme_picker_grid.dart';
|
||||||
|
|
||||||
class ChainSettingsDialog extends StatefulWidget {
|
class ChainSettingsDialog extends StatefulWidget {
|
||||||
|
|
@ -648,6 +649,8 @@ class _FaiSettingsDialogState extends State<ChainSettingsDialog> {
|
||||||
onSave: _saveHubAuthToken,
|
onSave: _saveHubAuthToken,
|
||||||
onClear: _clearHubAuthToken,
|
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.
|
/// Credentials panel for the operator's registry auth token.
|
||||||
/// The hub reads the same token via `~/.chain/registry-token`
|
/// The hub reads the same token via `~/.chain/registry-token`
|
||||||
/// (or the `CHAIN_REGISTRY_TOKEN` env var, which still wins)
|
/// (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,
|
/// signin wall — Forgejo with REQUIRE_SIGNIN_VIEW=true,
|
||||||
/// GitHub-private releases, etc.
|
/// GitHub-private releases, etc.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
390
lib/widgets/hub_auth_policy_panel.dart
Normal file
390
lib/widgets/hub_auth_policy_panel.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Unit tests for ChainLog — the central error log that
|
// 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
|
// surface of. The tests redirect the singleton at a temp file
|
||||||
// via `ChainLog.testPathOverride` so the real log under HOME is
|
// via `ChainLog.testPathOverride` so the real log under HOME is
|
||||||
// never touched.
|
// never touched.
|
||||||
|
|
|
||||||
159
test/hub_auth_policy_panel_test.dart
Normal file
159
test/hub_auth_policy_panel_test.dart
Normal file
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Hub fixture for integration-level tests.
|
// 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
|
// temporary directory, waits for its gRPC endpoint to answer
|
||||||
// `Healthy`, and exposes the port + a teardown so test files
|
// `Healthy`, and exposes the port + a teardown so test files
|
||||||
// can talk to a clean Hub without setting up an isolate-side
|
// can talk to a clean Hub without setting up an isolate-side
|
||||||
|
|
@ -8,13 +8,13 @@
|
||||||
//
|
//
|
||||||
// Cost / scope notes:
|
// Cost / scope notes:
|
||||||
//
|
//
|
||||||
// * The `fai` binary must be on the operator's PATH or at
|
// * The `chain` binary must be on the operator's PATH or at
|
||||||
// `target/release/fai` relative to the platform repo. We
|
// `target/release/chain` relative to the platform repo. We
|
||||||
// resolve those two paths in order; if neither exists, the
|
// resolve those two paths in order; if neither exists, the
|
||||||
// fixture skips the suite with a clear message rather than
|
// fixture skips the suite with a clear message rather than
|
||||||
// failing — these tests are opt-in.
|
// failing — these tests are opt-in.
|
||||||
// * The hub is started with `--quiet` (no log spam) against a
|
// * 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()`
|
// * Cleanup runs `process.kill(SIGTERM)` then `process.kill()`
|
||||||
// after a grace period. The temp dir is removed.
|
// after a grace period. The temp dir is removed.
|
||||||
// * Each fixture is independent — call `HubFixture.start()` in
|
// * Each fixture is independent — call `HubFixture.start()` in
|
||||||
|
|
@ -59,6 +59,13 @@ class HubFixture {
|
||||||
/// `markSkipped` instead of failing when the binary isn't
|
/// `markSkipped` instead of failing when the binary isn't
|
||||||
/// present — that's the desired behaviour in CI environments
|
/// present — that's the desired behaviour in CI environments
|
||||||
/// that don't build the binary first.
|
/// that don't build the binary first.
|
||||||
|
///
|
||||||
|
/// [configYaml], when given, is written to `<tempDir>/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<HubFixture?> start({
|
static Future<HubFixture?> start({
|
||||||
// A cold hub spends its first 30s building the curated-
|
// A cold hub spends its first 30s building the curated-
|
||||||
// model database + initialising SQLite. 60s gives that
|
// model database + initialising SQLite. 60s gives that
|
||||||
|
|
@ -66,6 +73,9 @@ class HubFixture {
|
||||||
// a slow one.
|
// a slow one.
|
||||||
Duration readyTimeout = const Duration(seconds: 60),
|
Duration readyTimeout = const Duration(seconds: 60),
|
||||||
bool skipIfBinaryMissing = true,
|
bool skipIfBinaryMissing = true,
|
||||||
|
String? configYaml,
|
||||||
|
Map<String, String> extraEnvironment = const {},
|
||||||
|
String? authToken,
|
||||||
}) async {
|
}) async {
|
||||||
final binary = await _resolveBinary();
|
final binary = await _resolveBinary();
|
||||||
if (binary == null) {
|
if (binary == null) {
|
||||||
|
|
@ -84,10 +94,16 @@ class HubFixture {
|
||||||
final port = await _pickFreePort();
|
final port = await _pickFreePort();
|
||||||
final addr = '127.0.0.1:$port';
|
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
|
// Run as a child process. `serve --bind` lets us avoid the
|
||||||
// default 50051 (and any local daemon the developer happens
|
// default 50051 (and any local daemon the developer happens
|
||||||
// to be running). CHAIN_DATA_DIR keeps the temp footprint
|
// 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(
|
final process = await Process.start(
|
||||||
binary,
|
binary,
|
||||||
['serve', '--bind', addr],
|
['serve', '--bind', addr],
|
||||||
|
|
@ -95,6 +111,8 @@ class HubFixture {
|
||||||
...Platform.environment,
|
...Platform.environment,
|
||||||
'CHAIN_DATA_DIR': tempDir.path,
|
'CHAIN_DATA_DIR': tempDir.path,
|
||||||
'CHAIN_MODULES_DIR': '${tempDir.path}/modules',
|
'CHAIN_MODULES_DIR': '${tempDir.path}/modules',
|
||||||
|
'CHAIN_CONFIG': ?configPath,
|
||||||
|
...extraEnvironment,
|
||||||
// Suppress info logs without a CLI flag — the binary
|
// Suppress info logs without a CLI flag — the binary
|
||||||
// respects RUST_LOG for tracing-subscriber.
|
// respects RUST_LOG for tracing-subscriber.
|
||||||
'RUST_LOG': 'warn',
|
'RUST_LOG': 'warn',
|
||||||
|
|
@ -111,6 +129,7 @@ class HubFixture {
|
||||||
// a hung hub is a test failure, not a hang.
|
// a hung hub is a test failure, not a hang.
|
||||||
final client = HubClient(
|
final client = HubClient(
|
||||||
endpoint: HubEndpoint(host: '127.0.0.1', port: port),
|
endpoint: HubEndpoint(host: '127.0.0.1', port: port),
|
||||||
|
authToken: authToken,
|
||||||
);
|
);
|
||||||
final deadline = DateTime.now().add(readyTimeout);
|
final deadline = DateTime.now().add(readyTimeout);
|
||||||
while (DateTime.now().isBefore(deadline)) {
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
|
|
@ -136,7 +155,7 @@ class HubFixture {
|
||||||
await tempDir.delete(recursive: true);
|
await tempDir.delete(recursive: true);
|
||||||
throw StateError(
|
throw StateError(
|
||||||
'Hub did not become healthy within $readyTimeout. '
|
'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.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
# 1. Generate today's proposals (calls the local Ollama by default).
|
||||||
./propose.sh
|
./propose.sh
|
||||||
|
|
||||||
# 2. Skim them — they sit under ~/.fai/today/proposals/<ISO-DATE>/
|
# 2. Skim them — they sit under ~/.chain/today/proposals/<ISO-DATE>/
|
||||||
ls ~/.fai/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.
|
# 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).
|
# 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.
|
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
|
- Calls only the System-AI you've already configured for Studio. The same
|
||||||
privacy mode you set there applies here.
|
privacy mode you set there applies here.
|
||||||
- Writes only into `~/.fai/today/`. Nothing in `~/.fai/data/` or
|
- Writes only into `~/.chain/today/`. Nothing in `~/.chain/data/` or
|
||||||
`~/.fai/config.yaml` is touched.
|
`~/.chain/config.yaml` is touched.
|
||||||
- Studio loads `active.yaml` at startup; if the file is missing or fails
|
- Studio loads `active.yaml` at startup; if the file is missing or fails
|
||||||
schema validation, the compiled-in fallback story renders. KRITIS
|
schema validation, the compiled-in fallback story renders. KRITIS
|
||||||
fresh installs see the fallback until and unless an operator accepts a
|
fresh installs see the fallback until and unless an operator accepts a
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
# tools/today/accept.sh <proposal-path>
|
# tools/today/accept.sh <proposal-path>
|
||||||
#
|
#
|
||||||
# Validates the chosen candidate, then atomically swaps it in as
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
if [ "${1:-}" = "" ]; then
|
if [ "${1:-}" = "" ]; then
|
||||||
|
|
@ -45,7 +45,7 @@ fi
|
||||||
|
|
||||||
# Atomic move via copy-then-rename so Studio never reads a torn
|
# Atomic move via copy-then-rename so Studio never reads a torn
|
||||||
# half-written file even if it polls during the swap.
|
# 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")"
|
mkdir -p "$(dirname "$dst")"
|
||||||
tmp="$(mktemp "${dst}.XXXXXX")"
|
tmp="$(mktemp "${dst}.XXXXXX")"
|
||||||
cp "$src" "$tmp"
|
cp "$src" "$tmp"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue