feat(store,doctor): surface the hub's trust + exposure data (0.77.0)
Some checks failed
Security / Security check (push) Failing after 1s

The trust-gate dialog replaces its generic 'no per-entry status yet
(alpha)' note with the hub's classified verification statement —
pinned store key / trusted publishers (green), installs without
signature checking (amber), install would be refused (red), bridge
entry (neutral) — via one shared describeInstallVerification mapping.
Against a pre-0.23 hub the field is empty and the old honest wording
stays (pinned by test).

Information architecture: policy-off is a GLOBAL fact, so it appears
as ONE ChainInlineHelp notice above the store grid instead of a
warning pill on every card (card noise); only 'blocked' — a genuine
per-source anomaly — earns a card pill. Doctor's host services show
the hub-classified network reach per endpoint (local only / private
network / publicly reachable with a protect-it hint / reach unknown).

Verified end-to-end against the live dev hub (guide harness): the
wire field arrives, the store page shows exactly one policy notice
and quiet cards; trust-gate variants captured via the dialog
harness. Suite 123 green.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-21 13:14:13 +02:00
parent 87afa4dc05
commit 2f076ccf29
15 changed files with 732 additions and 13 deletions

View file

@ -6,6 +6,28 @@ lockstep.
## Unreleased ## Unreleased
### Added — real trust + exposure data (0.77.0)
- **Per-entry install verification.** The hub now reports (since
0.23) how an install of each store entry would be verified under
the CURRENT policy — computed with the same resolvers the install
gate enforces, so display and enforcement cannot disagree. The
trust-gate dialog replaces its generic "no per-entry status yet
(alpha)" note with the classified statement: signature checked
via pinned store key / trusted publishers (green), installs
without signature checking (amber, policy off), install would be
refused (red), or bridge entry without a bundle signature. Store
cards stay quiet on the good path and wear an "unsigned"/
"blocked" warning pill only when installs are unverified —
same philosophy as the provenance pill. Against a pre-0.23 hub
everything keeps the old honest wording.
- **Service exposure on the doctor page.** Every declared host
service shows its network reach as classified by the hub from
the endpoint host (local only / private network / publicly
reachable / reach unknown — no DNS lookups). Publicly reachable
endpoints get the warning tone with a "make sure it is
protected" hint.
### Added — explain it where it happens (0.76.0) ### Added — explain it where it happens (0.76.0)
- **In-place help pattern.** New `ChainInlineHelp` (a one-sentence - **In-place help pattern.** New `ChainInlineHelp` (a one-sentence

View file

@ -16,8 +16,10 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:chain_studio/data/hub.dart' show StoreItem;
import 'package:chain_studio/l10n/app_localizations.dart'; import 'package:chain_studio/l10n/app_localizations.dart';
import 'package:chain_studio/pages/federation.dart'; import 'package:chain_studio/pages/federation.dart';
import 'package:chain_studio/widgets/chain_install_confirm.dart';
import '../test/support/fake_hub.dart'; import '../test/support/fake_hub.dart';
@ -41,6 +43,31 @@ Future<void> _shot(WidgetTester tester, String name) async {
print('dialog-shot: ${file.path}'); print('dialog-shot: ${file.path}');
} }
StoreItem _storeItem(String verification) => StoreItem(
name: 'text.extract',
taglineEn: 'Extract clean text',
taglineDe: 'Reinen Text extrahieren',
descriptionEn: '',
descriptionDe: '',
category: 'text',
tags: const [],
requiresCapabilities: const [],
requiresServices: const [],
license: 'Apache-2.0',
repository: '',
bestVersion: '0.1.0',
status: 'alpha',
installed: false,
featured: false,
iconUrl: '',
screenshotUrls: const [],
docsUrl: '',
kind: 'native',
provider: '',
source: 'bundled',
installVerification: verification,
);
void main() { void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@ -76,4 +103,38 @@ void main() {
findsOneWidget); findsOneWidget);
}); });
} }
// Trust gate with the hub-reported verification statement the
// pinned-key (good) and policy-off (warning) variants.
for (final (name, verification) in [
('trust-gate-pinned', 'pinned-key'),
('trust-gate-unverified', 'unverified'),
]) {
testWidgets('$name — dark', (tester) async {
SharedPreferences.setMockInitialValues({});
installFakeHub();
await tester.pumpWidget(
MaterialApp(
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.dark,
theme: ThemeData.light(useMaterial3: true),
darkTheme: ThemeData.dark(useMaterial3: true),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('de'),
builder: (context, child) =>
RepaintBoundary(key: _shotKey, child: child),
home: Scaffold(
body: Center(
child: ChainInstallConfirmDialog(
item: _storeItem(verification),
),
),
),
),
);
await tester.pump(const Duration(milliseconds: 200));
await _shot(tester, '$name-dark');
});
}
} }

View file

@ -4,7 +4,7 @@
/// Studio's own build version. Bump on every UI release so the /// Studio's own build version. Bump on every UI release so the
/// running app self-identifies. /// running app self-identifies.
const String kStudioVersion = '0.76.0'; const String kStudioVersion = '0.77.0';
const String kProductName = 'Ch∆In Studio'; const String kProductName = 'Ch∆In Studio';
const String kVendorName = 'Flemming.AI (F∆I)'; const String kVendorName = 'Flemming.AI (F∆I)';

View file

@ -937,6 +937,7 @@ class HubService {
source: e.source, source: e.source,
canonicalCategory: e.canonicalCategory, canonicalCategory: e.canonicalCategory,
canonicalCategoryLabel: e.canonicalCategoryLabel, canonicalCategoryLabel: e.canonicalCategoryLabel,
installVerification: e.installVerification,
), ),
) )
.toList(); .toList();
@ -1289,7 +1290,12 @@ class HubService {
services: services services: services
.map( .map(
(s) => (s) =>
ServiceEntry(name: s.name, endpoint: s.endpoint, tags: s.tags), ServiceEntry(
name: s.name,
endpoint: s.endpoint,
tags: s.tags,
exposure: s.exposure,
),
) )
.toList(), .toList(),
update: UpdateStatus( update: UpdateStatus(
@ -1386,10 +1392,16 @@ class ServiceEntry {
final String endpoint; final String endpoint;
final List<String> tags; final List<String> tags;
/// Network reach of [endpoint] as classified by the hub
/// (host part only, no DNS): "loopback" / "private" / "public" /
/// "unknown". Empty from pre-0.23 hubs.
final String exposure;
const ServiceEntry({ const ServiceEntry({
required this.name, required this.name,
required this.endpoint, required this.endpoint,
required this.tags, required this.tags,
this.exposure = '',
}); });
} }
@ -2233,6 +2245,15 @@ class StoreItem {
/// Human-readable label for [canonicalCategory] (e.g. "Data & Formats"). /// Human-readable label for [canonicalCategory] (e.g. "Data & Formats").
final String canonicalCategoryLabel; final String canonicalCategoryLabel;
/// How an install of this entry would be verified under the
/// hub's CURRENT policy — computed hub-side with the same
/// resolvers the install gate enforces, so this can never
/// disagree with what actually happens:
/// "pinned-key" / "trusted-publishers" / "unverified" /
/// "blocked" / "federated". Empty from pre-0.23 hubs the UI
/// keeps its generic note then.
final String installVerification;
/// True iff this entry is a Studio plugin/theme rather than a flow /// True iff this entry is a Studio plugin/theme rather than a flow
/// module drives the "Modules | Studio & Themes" store segment. /// module drives the "Modules | Studio & Themes" store segment.
bool get isStudioPlugin => canonicalCategory == 'studio-themes'; bool get isStudioPlugin => canonicalCategory == 'studio-themes';
@ -2264,5 +2285,6 @@ class StoreItem {
this.source = '', this.source = '',
this.canonicalCategory = '', this.canonicalCategory = '',
this.canonicalCategoryLabel = '', this.canonicalCategoryLabel = '',
this.installVerification = '',
}); });
} }

View file

@ -0,0 +1,65 @@
// Presentation of `StoreItem.installVerification` the hub's
// per-entry statement of how an install would be verified under
// the CURRENT policy (computed hub-side with the same resolvers
// the install gate enforces). One mapping, used by the trust-gate
// dialog, the module detail sheet and the store card, so every
// surface tells the same story.
import '../l10n/app_localizations.dart';
enum VerificationTone { good, neutral, warning, danger }
class InstallVerificationInfo {
final String label;
final String body;
final VerificationTone tone;
const InstallVerificationInfo({
required this.label,
required this.body,
required this.tone,
});
}
/// Maps the wire value to its localized presentation. Returns null
/// for an empty/unknown value (pre-0.23 hub) callers keep their
/// generic wording then instead of guessing.
InstallVerificationInfo? describeInstallVerification(
String wire,
AppLocalizations l,
) {
switch (wire) {
case 'pinned-key':
return InstallVerificationInfo(
label: l.verifPinnedKey,
body: l.verifPinnedKeyBody,
tone: VerificationTone.good,
);
case 'trusted-publishers':
return InstallVerificationInfo(
label: l.verifTrustedPublishers,
body: l.verifTrustedPublishersBody,
tone: VerificationTone.good,
);
case 'unverified':
return InstallVerificationInfo(
label: l.verifUnverified,
body: l.verifUnverifiedBody,
tone: VerificationTone.warning,
);
case 'blocked':
return InstallVerificationInfo(
label: l.verifBlocked,
body: l.verifBlockedBody,
tone: VerificationTone.danger,
);
case 'federated':
return InstallVerificationInfo(
label: l.verifFederated,
body: l.verifFederatedBody,
tone: VerificationTone.neutral,
);
default:
return null;
}
}

View file

@ -1189,6 +1189,12 @@
} }
}, },
"doctorSummaryDeclared": "deklariert", "doctorSummaryDeclared": "deklariert",
"svcExposureLoopback": "nur lokal",
"svcExposurePrivate": "privates Netz",
"svcExposurePublic": "öffentlich erreichbar",
"svcExposureUnknown": "Reichweite unbekannt",
"svcExposurePublicHint": "Dieser Dienst-Endpunkt liegt auf einer öffentlichen Adresse — prüfen Sie, ob er abgesichert ist (TLS, Auth, Firewall).",
"svcExposureUnknownHint": "Hostname statt IP-Adresse — der Hub löst zur Einstufung bewusst keine Namen auf.",
"doctorLinkStore": "Store öffnen", "doctorLinkStore": "Store öffnen",
"doctorLinkApprovals": "Freigaben öffnen", "doctorLinkApprovals": "Freigaben öffnen",
"doctorLinkAudit": "Protokoll öffnen", "doctorLinkAudit": "Protokoll öffnen",
@ -1622,6 +1628,19 @@
"installConfirmNeedsCapabilities": "Benötigte Fähigkeiten", "installConfirmNeedsCapabilities": "Benötigte Fähigkeiten",
"installConfirmTrustTitle": "Vertrauen & Sicherheit", "installConfirmTrustTitle": "Vertrauen & Sicherheit",
"installConfirmTrustBody": "Das Modul läuft in einer Sandbox: Es darf nur auf die Netzwerk-Endpunkte, Dateien und Umgebungsvariablen zugreifen, die es selbst deklariert — der Hub setzt diese Liste durch. Die vollständige Berechtigungsliste sehen Sie nach der Installation in den Modul-Details.", "installConfirmTrustBody": "Das Modul läuft in einer Sandbox: Es darf nur auf die Netzwerk-Endpunkte, Dateien und Umgebungsvariablen zugreifen, die es selbst deklariert — der Hub setzt diese Liste durch. Die vollständige Berechtigungsliste sehen Sie nach der Installation in den Modul-Details.",
"storePolicyUnverifiedNotice": "Die Signaturpflicht ist in der Hub-Richtlinie ausgeschaltet — Installationen werden nicht kryptografisch geprüft. Der Installations-Dialog zeigt den Status je Modul; für geprüfte Installationen security.require_signatures aktivieren.",
"verifPillUnverified": "unsigniert",
"verifPillBlocked": "blockiert",
"verifPinnedKey": "Signatur wird geprüft — hinterlegter Store-Schlüssel",
"verifPinnedKeyBody": "Der Hub verifiziert das Paket beim Installieren gegen den fest hinterlegten Schlüssel dieser Quelle. Ein manipuliertes Paket wird abgelehnt.",
"verifTrustedPublishers": "Signatur wird geprüft — vertrauenswürdige Herausgeber",
"verifTrustedPublishersBody": "Der Hub verifiziert die Signatur beim Installieren gegen die Liste vertrauenswürdiger Herausgeber. Ein Paket ohne gültige Signatur wird abgelehnt.",
"verifUnverified": "Wird ohne Signaturprüfung installiert",
"verifUnverifiedBody": "Die Signaturpflicht ist in der Hub-Richtlinie ausgeschaltet — dieses Paket wird beim Installieren nicht kryptografisch geprüft. Für geprüfte Installationen security.require_signatures aktivieren.",
"verifBlocked": "Installation würde abgelehnt",
"verifBlockedBody": "Die Richtlinie verlangt Signaturen, aber für diese Quelle ist kein Schlüsselmaterial hinterlegt — der Hub würde die Installation verweigern.",
"verifFederated": "Über Integration angebunden — keine Paket-Signatur",
"verifFederatedBody": "Dieser Eintrag ist kein installierbares Paket, sondern läuft über eine konfigurierte Integration (z. B. MCP oder n8n). Eine Paket-Signatur gibt es hier nicht; es gilt das Vertrauen in die Integration.",
"installConfirmSignatureNote": "Signaturen prüft der Hub beim Installieren, sofern die Signaturpflicht im Sicherheitsprofil aktiv ist. Einen Signaturstatus vorab zeigt der Store-Index derzeit noch nicht an (Alpha).", "installConfirmSignatureNote": "Signaturen prüft der Hub beim Installieren, sofern die Signaturpflicht im Sicherheitsprofil aktiv ist. Einen Signaturstatus vorab zeigt der Store-Index derzeit noch nicht an (Alpha).",
"settingsSidebarPinnedTitle": "Navigation immer ausgeklappt", "settingsSidebarPinnedTitle": "Navigation immer ausgeklappt",
"settingsSidebarPinnedBody": "Zeigt die Beschriftungen der Seitenleiste dauerhaft an, statt sie nur beim Überfahren mit der Maus einzublenden.", "settingsSidebarPinnedBody": "Zeigt die Beschriftungen der Seitenleiste dauerhaft an, statt sie nur beim Überfahren mit der Maus einzublenden.",

View file

@ -1213,6 +1213,12 @@
} }
}, },
"doctorSummaryDeclared": "declared", "doctorSummaryDeclared": "declared",
"svcExposureLoopback": "local only",
"svcExposurePrivate": "private network",
"svcExposurePublic": "publicly reachable",
"svcExposureUnknown": "reach unknown",
"svcExposurePublicHint": "This service endpoint sits on a public address — make sure it is protected (TLS, auth, firewall).",
"svcExposureUnknownHint": "Hostname instead of an IP address — the hub deliberately does not resolve names to classify.",
"doctorLinkStore": "Open the store", "doctorLinkStore": "Open the store",
"doctorLinkApprovals": "Open approvals", "doctorLinkApprovals": "Open approvals",
"doctorLinkAudit": "Open the audit log", "doctorLinkAudit": "Open the audit log",
@ -1646,6 +1652,19 @@
"installConfirmNeedsCapabilities": "Required capabilities", "installConfirmNeedsCapabilities": "Required capabilities",
"installConfirmTrustTitle": "Trust & security", "installConfirmTrustTitle": "Trust & security",
"installConfirmTrustBody": "The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.", "installConfirmTrustBody": "The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.",
"storePolicyUnverifiedNotice": "Signature enforcement is switched off in the hub policy — installs are not cryptographically verified. The install dialog shows the per-module status; enable security.require_signatures for verified installs.",
"verifPillUnverified": "unsigned",
"verifPillBlocked": "blocked",
"verifPinnedKey": "Signature checked — pinned store key",
"verifPinnedKeyBody": "The hub verifies the bundle at install time against this source's pinned key. A tampered bundle is refused.",
"verifTrustedPublishers": "Signature checked — trusted publishers",
"verifTrustedPublishersBody": "The hub verifies the signature at install time against the trusted-publisher list. A bundle without a valid signature is refused.",
"verifUnverified": "Installs without signature checking",
"verifUnverifiedBody": "Signature enforcement is switched off in the hub policy — this bundle is not cryptographically verified at install time. Enable security.require_signatures for verified installs.",
"verifBlocked": "An install would be refused",
"verifBlockedBody": "The policy requires signatures, but no key material applies to this source — the hub would refuse the install.",
"verifFederated": "Connected via an integration — no bundle signature",
"verifFederatedBody": "This entry is not an installable bundle; it runs through a configured integration (e.g. MCP or n8n). There is no bundle signature — trust follows the integration.",
"installConfirmSignatureNote": "The hub verifies signatures at install time whenever the security profile requires them. The store index does not yet show a per-entry signature status up front (alpha).", "installConfirmSignatureNote": "The hub verifies signatures at install time whenever the security profile requires them. The store index does not yet show a per-entry signature status up front (alpha).",
"settingsSidebarPinnedTitle": "Keep the navigation expanded", "settingsSidebarPinnedTitle": "Keep the navigation expanded",
"settingsSidebarPinnedBody": "Shows the sidebar labels permanently instead of only while hovering with the mouse.", "settingsSidebarPinnedBody": "Shows the sidebar labels permanently instead of only while hovering with the mouse.",

View file

@ -3692,6 +3692,42 @@ abstract class AppLocalizations {
/// **'declared'** /// **'declared'**
String get doctorSummaryDeclared; String get doctorSummaryDeclared;
/// No description provided for @svcExposureLoopback.
///
/// In en, this message translates to:
/// **'local only'**
String get svcExposureLoopback;
/// No description provided for @svcExposurePrivate.
///
/// In en, this message translates to:
/// **'private network'**
String get svcExposurePrivate;
/// No description provided for @svcExposurePublic.
///
/// In en, this message translates to:
/// **'publicly reachable'**
String get svcExposurePublic;
/// No description provided for @svcExposureUnknown.
///
/// In en, this message translates to:
/// **'reach unknown'**
String get svcExposureUnknown;
/// No description provided for @svcExposurePublicHint.
///
/// In en, this message translates to:
/// **'This service endpoint sits on a public address — make sure it is protected (TLS, auth, firewall).'**
String get svcExposurePublicHint;
/// No description provided for @svcExposureUnknownHint.
///
/// In en, this message translates to:
/// **'Hostname instead of an IP address — the hub deliberately does not resolve names to classify.'**
String get svcExposureUnknownHint;
/// No description provided for @doctorLinkStore. /// No description provided for @doctorLinkStore.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
@ -4898,6 +4934,84 @@ abstract class AppLocalizations {
/// **'The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.'** /// **'The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.'**
String get installConfirmTrustBody; String get installConfirmTrustBody;
/// No description provided for @storePolicyUnverifiedNotice.
///
/// In en, this message translates to:
/// **'Signature enforcement is switched off in the hub policy — installs are not cryptographically verified. The install dialog shows the per-module status; enable security.require_signatures for verified installs.'**
String get storePolicyUnverifiedNotice;
/// No description provided for @verifPillUnverified.
///
/// In en, this message translates to:
/// **'unsigned'**
String get verifPillUnverified;
/// No description provided for @verifPillBlocked.
///
/// In en, this message translates to:
/// **'blocked'**
String get verifPillBlocked;
/// No description provided for @verifPinnedKey.
///
/// In en, this message translates to:
/// **'Signature checked — pinned store key'**
String get verifPinnedKey;
/// No description provided for @verifPinnedKeyBody.
///
/// In en, this message translates to:
/// **'The hub verifies the bundle at install time against this source\'s pinned key. A tampered bundle is refused.'**
String get verifPinnedKeyBody;
/// No description provided for @verifTrustedPublishers.
///
/// In en, this message translates to:
/// **'Signature checked — trusted publishers'**
String get verifTrustedPublishers;
/// No description provided for @verifTrustedPublishersBody.
///
/// In en, this message translates to:
/// **'The hub verifies the signature at install time against the trusted-publisher list. A bundle without a valid signature is refused.'**
String get verifTrustedPublishersBody;
/// No description provided for @verifUnverified.
///
/// In en, this message translates to:
/// **'Installs without signature checking'**
String get verifUnverified;
/// No description provided for @verifUnverifiedBody.
///
/// In en, this message translates to:
/// **'Signature enforcement is switched off in the hub policy — this bundle is not cryptographically verified at install time. Enable security.require_signatures for verified installs.'**
String get verifUnverifiedBody;
/// No description provided for @verifBlocked.
///
/// In en, this message translates to:
/// **'An install would be refused'**
String get verifBlocked;
/// No description provided for @verifBlockedBody.
///
/// In en, this message translates to:
/// **'The policy requires signatures, but no key material applies to this source — the hub would refuse the install.'**
String get verifBlockedBody;
/// No description provided for @verifFederated.
///
/// In en, this message translates to:
/// **'Connected via an integration — no bundle signature'**
String get verifFederated;
/// No description provided for @verifFederatedBody.
///
/// In en, this message translates to:
/// **'This entry is not an installable bundle; it runs through a configured integration (e.g. MCP or n8n). There is no bundle signature — trust follows the integration.'**
String get verifFederatedBody;
/// No description provided for @installConfirmSignatureNote. /// No description provided for @installConfirmSignatureNote.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -2149,6 +2149,26 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get doctorSummaryDeclared => 'deklariert'; String get doctorSummaryDeclared => 'deklariert';
@override
String get svcExposureLoopback => 'nur lokal';
@override
String get svcExposurePrivate => 'privates Netz';
@override
String get svcExposurePublic => 'öffentlich erreichbar';
@override
String get svcExposureUnknown => 'Reichweite unbekannt';
@override
String get svcExposurePublicHint =>
'Dieser Dienst-Endpunkt liegt auf einer öffentlichen Adresse — prüfen Sie, ob er abgesichert ist (TLS, Auth, Firewall).';
@override
String get svcExposureUnknownHint =>
'Hostname statt IP-Adresse — der Hub löst zur Einstufung bewusst keine Namen auf.';
@override @override
String get doctorLinkStore => 'Store öffnen'; String get doctorLinkStore => 'Store öffnen';
@ -2886,6 +2906,54 @@ class AppLocalizationsDe extends AppLocalizations {
String get installConfirmTrustBody => String get installConfirmTrustBody =>
'Das Modul läuft in einer Sandbox: Es darf nur auf die Netzwerk-Endpunkte, Dateien und Umgebungsvariablen zugreifen, die es selbst deklariert — der Hub setzt diese Liste durch. Die vollständige Berechtigungsliste sehen Sie nach der Installation in den Modul-Details.'; 'Das Modul läuft in einer Sandbox: Es darf nur auf die Netzwerk-Endpunkte, Dateien und Umgebungsvariablen zugreifen, die es selbst deklariert — der Hub setzt diese Liste durch. Die vollständige Berechtigungsliste sehen Sie nach der Installation in den Modul-Details.';
@override
String get storePolicyUnverifiedNotice =>
'Die Signaturpflicht ist in der Hub-Richtlinie ausgeschaltet — Installationen werden nicht kryptografisch geprüft. Der Installations-Dialog zeigt den Status je Modul; für geprüfte Installationen security.require_signatures aktivieren.';
@override
String get verifPillUnverified => 'unsigniert';
@override
String get verifPillBlocked => 'blockiert';
@override
String get verifPinnedKey =>
'Signatur wird geprüft — hinterlegter Store-Schlüssel';
@override
String get verifPinnedKeyBody =>
'Der Hub verifiziert das Paket beim Installieren gegen den fest hinterlegten Schlüssel dieser Quelle. Ein manipuliertes Paket wird abgelehnt.';
@override
String get verifTrustedPublishers =>
'Signatur wird geprüft — vertrauenswürdige Herausgeber';
@override
String get verifTrustedPublishersBody =>
'Der Hub verifiziert die Signatur beim Installieren gegen die Liste vertrauenswürdiger Herausgeber. Ein Paket ohne gültige Signatur wird abgelehnt.';
@override
String get verifUnverified => 'Wird ohne Signaturprüfung installiert';
@override
String get verifUnverifiedBody =>
'Die Signaturpflicht ist in der Hub-Richtlinie ausgeschaltet — dieses Paket wird beim Installieren nicht kryptografisch geprüft. Für geprüfte Installationen security.require_signatures aktivieren.';
@override
String get verifBlocked => 'Installation würde abgelehnt';
@override
String get verifBlockedBody =>
'Die Richtlinie verlangt Signaturen, aber für diese Quelle ist kein Schlüsselmaterial hinterlegt — der Hub würde die Installation verweigern.';
@override
String get verifFederated =>
'Über Integration angebunden — keine Paket-Signatur';
@override
String get verifFederatedBody =>
'Dieser Eintrag ist kein installierbares Paket, sondern läuft über eine konfigurierte Integration (z. B. MCP oder n8n). Eine Paket-Signatur gibt es hier nicht; es gilt das Vertrauen in die Integration.';
@override @override
String get installConfirmSignatureNote => String get installConfirmSignatureNote =>
'Signaturen prüft der Hub beim Installieren, sofern die Signaturpflicht im Sicherheitsprofil aktiv ist. Einen Signaturstatus vorab zeigt der Store-Index derzeit noch nicht an (Alpha).'; 'Signaturen prüft der Hub beim Installieren, sofern die Signaturpflicht im Sicherheitsprofil aktiv ist. Einen Signaturstatus vorab zeigt der Store-Index derzeit noch nicht an (Alpha).';

View file

@ -2153,6 +2153,26 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get doctorSummaryDeclared => 'declared'; String get doctorSummaryDeclared => 'declared';
@override
String get svcExposureLoopback => 'local only';
@override
String get svcExposurePrivate => 'private network';
@override
String get svcExposurePublic => 'publicly reachable';
@override
String get svcExposureUnknown => 'reach unknown';
@override
String get svcExposurePublicHint =>
'This service endpoint sits on a public address — make sure it is protected (TLS, auth, firewall).';
@override
String get svcExposureUnknownHint =>
'Hostname instead of an IP address — the hub deliberately does not resolve names to classify.';
@override @override
String get doctorLinkStore => 'Open the store'; String get doctorLinkStore => 'Open the store';
@ -2882,6 +2902,52 @@ class AppLocalizationsEn extends AppLocalizations {
String get installConfirmTrustBody => String get installConfirmTrustBody =>
'The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.'; 'The module runs in a sandbox: it may only touch the network endpoints, files, and environment variables it declares itself — the hub enforces that list. The full permission list is visible in the module details after installation.';
@override
String get storePolicyUnverifiedNotice =>
'Signature enforcement is switched off in the hub policy — installs are not cryptographically verified. The install dialog shows the per-module status; enable security.require_signatures for verified installs.';
@override
String get verifPillUnverified => 'unsigned';
@override
String get verifPillBlocked => 'blocked';
@override
String get verifPinnedKey => 'Signature checked — pinned store key';
@override
String get verifPinnedKeyBody =>
'The hub verifies the bundle at install time against this source\'s pinned key. A tampered bundle is refused.';
@override
String get verifTrustedPublishers => 'Signature checked — trusted publishers';
@override
String get verifTrustedPublishersBody =>
'The hub verifies the signature at install time against the trusted-publisher list. A bundle without a valid signature is refused.';
@override
String get verifUnverified => 'Installs without signature checking';
@override
String get verifUnverifiedBody =>
'Signature enforcement is switched off in the hub policy — this bundle is not cryptographically verified at install time. Enable security.require_signatures for verified installs.';
@override
String get verifBlocked => 'An install would be refused';
@override
String get verifBlockedBody =>
'The policy requires signatures, but no key material applies to this source — the hub would refuse the install.';
@override
String get verifFederated =>
'Connected via an integration — no bundle signature';
@override
String get verifFederatedBody =>
'This entry is not an installable bundle; it runs through a configured integration (e.g. MCP or n8n). There is no bundle signature — trust follows the integration.';
@override @override
String get installConfirmSignatureNote => String get installConfirmSignatureNote =>
'The hub verifies signatures at install time whenever the security profile requires them. The store index does not yet show a per-entry signature status up front (alpha).'; 'The hub verifies signatures at install time whenever the security profile requires them. The store index does not yet show a per-entry signature status up front (alpha).';

View file

@ -1159,6 +1159,9 @@ class _ServicesPanel extends StatelessWidget {
), ),
), ),
const Spacer(), const Spacer(),
_ServiceExposurePill(
exposure: snapshot.services[i].exposure,
),
for (final tag in snapshot.services[i].tags) ...[ for (final tag in snapshot.services[i].tags) ...[
ChainPill(label: tag, tone: ChainPillTone.neutral), ChainPill(label: tag, tone: ChainPillTone.neutral),
const SizedBox(width: ChainSpace.xs), const SizedBox(width: ChainSpace.xs),
@ -1173,6 +1176,43 @@ class _ServicesPanel extends StatelessWidget {
} }
} }
/// Network-reach pill per declared service, from the hub's
/// endpoint classification (`DeclaredService.exposure`). Public
/// endpoints get the warning tone a host service reachable from
/// outside is the one thing an operator should notice here.
/// Empty (pre-0.23 hub) renders nothing.
class _ServiceExposurePill extends StatelessWidget {
final String exposure;
const _ServiceExposurePill({required this.exposure});
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
final (label, tone, hint) = switch (exposure) {
'loopback' => (l.svcExposureLoopback, ChainPillTone.neutral, ''),
'private' => (l.svcExposurePrivate, ChainPillTone.neutral, ''),
'public' => (
l.svcExposurePublic,
ChainPillTone.warning,
l.svcExposurePublicHint,
),
'unknown' => (
l.svcExposureUnknown,
ChainPillTone.neutral,
l.svcExposureUnknownHint,
),
_ => ('', ChainPillTone.neutral, ''),
};
if (label.isEmpty) return const SizedBox.shrink();
final pill = ChainPill(label: label, tone: tone);
return Padding(
padding: const EdgeInsets.only(right: ChainSpace.xs),
child: hint.isEmpty ? pill : Tooltip(message: hint, child: pill),
);
}
}
class _UpdateBanner extends StatefulWidget { class _UpdateBanner extends StatefulWidget {
final UpdateStatus status; final UpdateStatus status;

View file

@ -14,6 +14,7 @@ import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
import '../data/error_presentation.dart'; import '../data/error_presentation.dart';
import '../data/hub.dart'; import '../data/hub.dart';
import '../data/install_verification.dart';
import '../data/system_actions.dart'; import '../data/system_actions.dart';
import '../data/today_story_loader.dart'; import '../data/today_story_loader.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
@ -383,6 +384,23 @@ class _StorePageState extends State<StorePage> {
setState(() => _showStudio = v), setState(() => _showStudio = v),
), ),
), ),
// ONE page-level notice when the hub says
// installs run unverified (policy off)
// a global fact, so it appears once here
// instead of as a pill on every card. Old
// hubs report nothing => nothing shows.
if (raw.any(
(i) => i.installVerification == 'unverified',
))
Padding(
padding: const EdgeInsets.only(
bottom: ChainSpace.md,
),
child: ChainInlineHelp(
icon: Icons.gpp_maybe_outlined,
text: l.storePolicyUnverifiedNotice,
),
),
if (_aiThinking || if (_aiThinking ||
_aiAnswer != null || _aiAnswer != null ||
_aiError != null) ...[ _aiError != null) ...[
@ -2136,6 +2154,7 @@ class _StoreCardState extends State<_StoreCard> {
const SizedBox(width: ChainSpace.xs), const SizedBox(width: ChainSpace.xs),
], ],
_ProvenancePill(item: item), _ProvenancePill(item: item),
_VerificationPill(item: item),
const Spacer(), const Spacer(),
if (_hasUpdate) if (_hasUpdate)
FilledButton.icon( FilledButton.icon(
@ -3809,6 +3828,39 @@ class _ProvenancePill extends StatelessWidget {
} }
} }
/// Signature-verification warning pill. Same philosophy as the
/// provenance pill: the GOOD path (signature checked at install)
/// stays quiet, and so does the policy-off case that one is a
/// GLOBAL property and lives as ONE notice above the grid, not as
/// a repeated badge on every card. Only "blocked" (this source
/// has no applicable key material although the policy demands
/// signatures) is a genuine per-source anomaly worth a badge.
class _VerificationPill extends StatelessWidget {
final StoreItem item;
const _VerificationPill({required this.item});
@override
Widget build(BuildContext context) {
final l = AppLocalizations.of(context)!;
if (item.installVerification != 'blocked') {
return const SizedBox.shrink();
}
final info = describeInstallVerification(item.installVerification, l)!;
return Padding(
padding: const EdgeInsets.only(left: ChainSpace.xs),
child: Tooltip(
message: info.body,
child: ChainPill(
label: l.verifPillBlocked,
tone: ChainPillTone.danger,
icon: Icons.gpp_bad_outlined,
),
),
);
}
}
/// Store-side version picker. Same shape as the module-sheet /// Store-side version picker. Same shape as the module-sheet
/// version picker but lives here so the Store page doesn't take /// version picker but lives here so the Store page doesn't take
/// a dependency on the module-sheet's private widgets. The /// a dependency on the module-sheet's private widgets. The

View file

@ -5,14 +5,16 @@
// quick fix) now routes through this dialog, which shows what // quick fix) now routes through this dialog, which shows what
// the hub actually knows before anything is downloaded origin // the hub actually knows before anything is downloaded origin
// store, version, license, maturity, required services plus // store, version, license, maturity, required services plus
// an honest note on when signature verification happens. No // the per-entry verification statement the hub computes with the
// fake "verified" badge: per-entry signature status is not in // SAME resolvers the install gate enforces (pinned key / trusted
// the store index yet, and the dialog says so instead of // publishers / policy off / blocked / federated). Against a
// pretending. // pre-0.23 hub the field is empty and the dialog keeps its
// generic when-verification-happens note instead of guessing.
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../data/hub.dart'; import '../data/hub.dart';
import '../data/install_verification.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../theme/tokens.dart'; import '../theme/tokens.dart';
@ -184,12 +186,65 @@ class ChainInstallConfirmDialog extends StatelessWidget {
), ),
), ),
const SizedBox(height: ChainSpace.xs), const SizedBox(height: ChainSpace.xs),
Text( Builder(
builder: (context) {
final info = describeInstallVerification(
item.installVerification,
l,
);
if (info == null) {
// Pre-0.23 hub: keep the generic note.
return Text(
l.installConfirmSignatureNote, l.installConfirmSignatureNote,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
height: 1.4, height: 1.4,
), ),
);
}
final color = switch (info.tone) {
VerificationTone.good => ChainColors.success,
VerificationTone.warning => ChainColors.warning,
VerificationTone.danger => theme.colorScheme.error,
VerificationTone.neutral =>
theme.colorScheme.onSurfaceVariant,
};
final icon = switch (info.tone) {
VerificationTone.good => Icons.verified_outlined,
VerificationTone.warning =>
Icons.warning_amber_outlined,
VerificationTone.danger => Icons.gpp_bad_outlined,
VerificationTone.neutral => Icons.link_outlined,
};
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 14, color: color),
const SizedBox(width: 6),
Expanded(
child: Text(
info.label,
style: theme.textTheme.bodySmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 2),
Text(
info.body,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.4,
),
),
],
);
},
), ),
], ],
), ),

View file

@ -1,7 +1,7 @@
name: chain_studio name: chain_studio
description: "Ch∆In Studio — desktop GUI for the Ch∆In hub" description: "Ch∆In Studio — desktop GUI for the Ch∆In hub"
publish_to: 'none' publish_to: 'none'
version: 0.76.0 version: 0.77.0
environment: environment:
sdk: ^3.11.0-200.1.beta sdk: ^3.11.0-200.1.beta

View file

@ -0,0 +1,116 @@
// Presentation of the hub's per-entry install-verification
// statement. The mapping is the single source for the trust-gate
// dialog, the store card pill and the detail sheet these tests
// pin the wire contract and the honest-fallback rule (unknown or
// empty => null => callers keep their generic wording).
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio/data/hub.dart';
import 'package:chain_studio/data/install_verification.dart';
import 'package:chain_studio/l10n/app_localizations.dart';
import 'package:chain_studio/widgets/chain_install_confirm.dart';
StoreItem _item({String verification = ''}) => StoreItem(
name: 'text.extract',
taglineEn: '',
taglineDe: '',
descriptionEn: '',
descriptionDe: '',
category: 'text',
tags: const [],
requiresCapabilities: const [],
requiresServices: const [],
license: 'Apache-2.0',
repository: '',
bestVersion: '0.1.0',
status: 'alpha',
installed: false,
featured: false,
iconUrl: '',
screenshotUrls: const [],
docsUrl: '',
kind: 'native',
provider: '',
source: 'bundled',
installVerification: verification,
);
Widget _host(Widget child) => MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
locale: const Locale('de'),
home: Scaffold(body: child),
);
Future<AppLocalizations> _l10n(WidgetTester tester) async {
late AppLocalizations l;
await tester.pumpWidget(
_host(
Builder(
builder: (context) {
l = AppLocalizations.of(context)!;
return const SizedBox();
},
),
),
);
return l;
}
void main() {
testWidgets('wire values map to tones; unknown maps to null', (
tester,
) async {
final l = await _l10n(tester);
expect(
describeInstallVerification('pinned-key', l)!.tone,
VerificationTone.good,
);
expect(
describeInstallVerification('trusted-publishers', l)!.tone,
VerificationTone.good,
);
expect(
describeInstallVerification('unverified', l)!.tone,
VerificationTone.warning,
);
expect(
describeInstallVerification('blocked', l)!.tone,
VerificationTone.danger,
);
expect(
describeInstallVerification('federated', l)!.tone,
VerificationTone.neutral,
);
// Pre-0.23 hub / future value: no guessing.
expect(describeInstallVerification('', l), isNull);
expect(describeInstallVerification('something-new', l), isNull);
});
testWidgets('trust gate states the classified verification', (tester) async {
await tester.pumpWidget(
_host(ChainInstallConfirmDialog(item: _item(verification: 'unverified'))),
);
await tester.pumpAndSettle();
expect(
find.text('Wird ohne Signaturprüfung installiert'),
findsOneWidget,
);
// The generic alpha note must be gone when the hub reports.
expect(
find.textContaining('zeigt der Store-Index derzeit noch nicht'),
findsNothing,
);
});
testWidgets('trust gate keeps the generic note for old hubs', (tester) async {
await tester.pumpWidget(_host(ChainInstallConfirmDialog(item: _item())));
await tester.pumpAndSettle();
expect(
find.textContaining('zeigt der Store-Index derzeit noch nicht'),
findsOneWidget,
);
});
}