diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 922171d..59487a2 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -983,6 +983,14 @@ class HubService { canonicalCategoryLabel: e.canonicalCategoryLabel, installVerification: e.installVerification, maintainers: e.maintainers, + dataSource: e.hasDataSource() + ? DataProvenance( + name: e.dataSource.name, + url: e.dataSource.url, + license: e.dataSource.license, + attribution: e.dataSource.attribution, + ) + : null, ), ) .toList(); @@ -2454,6 +2462,11 @@ class StoreItem { /// any — the detail sheet renders an honest "not specified". final List maintainers; + /// Where a `source.*` module's data comes from, and under what + /// terms. Null for every other module: the module's own licence + /// covers its code, this covers material it reaches at runtime. + final DataProvenance? dataSource; + /// 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 @@ -2496,5 +2509,30 @@ class StoreItem { this.canonicalCategoryLabel = '', this.installVerification = '', this.maintainers = const [], + this.dataSource, + }); +} + +/// Provenance of the material a source module fetches. +class DataProvenance { + /// Publisher, as a person would name it. + final String name; + + /// Canonical URL of the upstream source. + final String url; + + /// Terms in plain words, not SPDX: statutes carry no software + /// licence at all. + final String license; + + /// Attribution the operator must carry with the output; empty when + /// the upstream requires none. + final String attribution; + + const DataProvenance({ + required this.name, + required this.url, + required this.license, + required this.attribution, }); } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c4f1e1a..08cb179 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1670,6 +1670,10 @@ "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.", "storeSectionMaintainers": "Maintainer", + "storeSectionDataSource": "Datenquelle", + "storeDataSourceLicense": "Bedingungen", + "storeDataSourceAttribution": "Namensnennung erforderlich", + "storeDataSourceNote": "Diese Bedingungen gelten für das Material, das dieses Modul abruft, nicht für das Modul selbst. Es lädt in Ihrem Auftrag und liefert keine Kopie der Daten mit.", "storeMaintainersNone": "nicht angegeben", "installConfirmMaintainers": "Maintainer", "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.", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index da04c7f..eeb2da6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1711,6 +1711,10 @@ "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.", "storeSectionMaintainers": "Maintainers", + "storeSectionDataSource": "Data source", + "storeDataSourceLicense": "Terms", + "storeDataSourceAttribution": "Attribution required", + "storeDataSourceNote": "These terms cover the material this module fetches, not the module itself. It downloads on your behalf and ships no copy of the data.", "storeMaintainersNone": "not specified", "installConfirmMaintainers": "Maintainers", "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.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index d1185b0..0e6b66c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5174,6 +5174,30 @@ abstract class AppLocalizations { /// **'Maintainers'** String get storeSectionMaintainers; + /// No description provided for @storeSectionDataSource. + /// + /// In en, this message translates to: + /// **'Data source'** + String get storeSectionDataSource; + + /// No description provided for @storeDataSourceLicense. + /// + /// In en, this message translates to: + /// **'Terms'** + String get storeDataSourceLicense; + + /// No description provided for @storeDataSourceAttribution. + /// + /// In en, this message translates to: + /// **'Attribution required'** + String get storeDataSourceAttribution; + + /// No description provided for @storeDataSourceNote. + /// + /// In en, this message translates to: + /// **'These terms cover the material this module fetches, not the module itself. It downloads on your behalf and ships no copy of the data.'** + String get storeDataSourceNote; + /// No description provided for @storeMaintainersNone. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 6970439..a0f9a3a 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3056,6 +3056,19 @@ class AppLocalizationsDe extends AppLocalizations { @override String get storeSectionMaintainers => 'Maintainer'; + @override + String get storeSectionDataSource => 'Datenquelle'; + + @override + String get storeDataSourceLicense => 'Bedingungen'; + + @override + String get storeDataSourceAttribution => 'Namensnennung erforderlich'; + + @override + String get storeDataSourceNote => + 'Diese Bedingungen gelten für das Material, das dieses Modul abruft, nicht für das Modul selbst. Es lädt in Ihrem Auftrag und liefert keine Kopie der Daten mit.'; + @override String get storeMaintainersNone => 'nicht angegeben'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 587e16c..abc3267 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3052,6 +3052,19 @@ class AppLocalizationsEn extends AppLocalizations { @override String get storeSectionMaintainers => 'Maintainers'; + @override + String get storeSectionDataSource => 'Data source'; + + @override + String get storeDataSourceLicense => 'Terms'; + + @override + String get storeDataSourceAttribution => 'Attribution required'; + + @override + String get storeDataSourceNote => + 'These terms cover the material this module fetches, not the module itself. It downloads on your behalf and ships no copy of the data.'; + @override String get storeMaintainersNone => 'not specified'; diff --git a/lib/pages/store.dart b/lib/pages/store.dart index 6897976..0b2cc0c 100644 --- a/lib/pages/store.dart +++ b/lib/pages/store.dart @@ -253,8 +253,9 @@ class _StorePageState extends State { icon: const Icon(Icons.add_business_outlined, size: 16), label: Text(l.storesManagerButton), onPressed: () => ChainStoresDialog.show(context), - style: - OutlinedButton.styleFrom(visualDensity: VisualDensity.compact), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + ), ), ), IconButton( @@ -365,8 +366,7 @@ class _StorePageState extends State { // Modules vs Studio plugins/themes — a theme // extends the GUI, a module runs in a flow. Padding( - padding: - const EdgeInsets.only(bottom: ChainSpace.md), + padding: const EdgeInsets.only(bottom: ChainSpace.md), child: ChainSegments( items: [ ChainSegmentItem( @@ -381,8 +381,7 @@ class _StorePageState extends State { ), ], value: _showStudio, - onChanged: (v) => - setState(() => _showStudio = v), + onChanged: (v) => setState(() => _showStudio = v), ), ), // ONE page-level notice when the hub says @@ -1657,24 +1656,24 @@ class _StoreGrid extends StatelessWidget { }); Widget grid(List gi) => GridView.builder( - padding: EdgeInsets.zero, - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: cols, - mainAxisSpacing: ChainSpace.md, - crossAxisSpacing: ChainSpace.md, - mainAxisExtent: 168, - ), - itemCount: gi.length, - itemBuilder: (context, i) => _StoreCard( - item: gi[i], - locale: locale, - installedVersion: installedVersions[gi[i].name], - onTap: () => onTap(gi[i]), - onInstall: () => onInstall(gi[i]), - ), - ); + padding: EdgeInsets.zero, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + mainAxisSpacing: ChainSpace.md, + crossAxisSpacing: ChainSpace.md, + mainAxisExtent: 168, + ), + itemCount: gi.length, + itemBuilder: (context, i) => _StoreCard( + item: gi[i], + locale: locale, + installedVersion: installedVersions[gi[i].name], + onTap: () => onTap(gi[i]), + onInstall: () => onInstall(gi[i]), + ), + ); // A single category (e.g. the store is already filtered to // one) renders without a redundant header. @@ -1695,20 +1694,17 @@ class _StoreGrid extends StatelessWidget { children: [ Text( _canonicalCatLabel(context, slug, labels[slug] ?? ''), - style: Theme.of(context) - .textTheme - .titleSmall - ?.copyWith(fontWeight: FontWeight.w700), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), ), const SizedBox(width: ChainSpace.sm), Text( '${groups[slug]!.length}', style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, - fontFeatures: const [FontFeature.tabularFigures()], - ), + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontFeatures: const [FontFeature.tabularFigures()], + ), ), ], ), @@ -1932,18 +1928,14 @@ class _FeaturedTile extends StatelessWidget { ), ), const SizedBox(width: ChainSpace.xs), - if (item.status.isNotEmpty) - _statusPill(context, item.status), + if (item.status.isNotEmpty) _statusPill(context, item.status), // License on the card, not only in the detail // sheet — buyers scan the grid for exactly this // (usertest finding: no license/cost signal per // module before clicking). if (item.license.isNotEmpty) ...[ const SizedBox(width: ChainSpace.xs), - ChainPill( - label: item.license, - tone: ChainPillTone.neutral, - ), + ChainPill(label: item.license, tone: ChainPillTone.neutral), ], const Spacer(), if (item.installed) @@ -2230,7 +2222,9 @@ class _StoreDetailSheet extends StatefulWidget { backgroundColor: Theme.of(context).colorScheme.surfaceContainer, elevation: 8, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(ChainRadius.md)), + borderRadius: BorderRadius.vertical( + top: Radius.circular(ChainRadius.md), + ), ), builder: (_) => _StoreDetailSheet(item: item, locale: locale), ); @@ -2314,8 +2308,10 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { Future _install() async { // Same trust gate as the store card — the detail sheet's // install button must not be a quieter bypass. - final confirmed = - await ChainInstallConfirmDialog.show(context, widget.item); + final confirmed = await ChainInstallConfirmDialog.show( + context, + widget.item, + ); if (!confirmed || !mounted) return; setState(() { _busy = true; @@ -2686,6 +2682,8 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { _DocsPanel(text: _docsResult!.text), const SizedBox(height: ChainSpace.lg), ], + if (item.dataSource != null) + StoreDataSourceSection(source: item.dataSource!), StoreMaintainersSection(maintainers: item.maintainers), if (item.repository.isNotEmpty) ...[ _SectionHeader(l.storeSectionSource), @@ -2781,7 +2779,9 @@ class _StoreDetailSheetState extends State<_StoreDetailSheet> { ), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(ChainRadius.sm), + borderRadius: BorderRadius.circular( + ChainRadius.sm, + ), border: Border.all( color: theme.colorScheme.outlineVariant, ), @@ -2969,7 +2969,11 @@ class _InstallProgressDialogState extends State<_InstallProgressDialog> { mainAxisSize: MainAxisSize.min, children: [ if (done) ...[ - Icon(Icons.check_circle_outline, size: 32, color: ChainColors.success), + Icon( + Icons.check_circle_outline, + size: 32, + color: ChainColors.success, + ), const SizedBox(height: ChainSpace.md), Text( l.storeInstalledToast(_result!.name, _result!.version), @@ -3947,6 +3951,84 @@ class StoreMaintainersSection extends StatelessWidget { } } +/// Provenance block for `source.*` modules: who publishes the data +/// the module fetches, and under what terms. Sits above the install +/// button because it is a decision input, not a footnote — the terms +/// of the material are separate from the module's own licence, and +/// an operator taking on an attribution duty should see it first. +/// Public so the widget test pumps it directly. +class StoreDataSourceSection extends StatelessWidget { + final DataProvenance source; + + const StoreDataSourceSection({super.key, required this.source}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; + final muted = theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionHeader(l.storeSectionDataSource), + const SizedBox(height: ChainSpace.sm), + SelectableText(source.name, style: theme.textTheme.bodySmall), + if (source.url.isNotEmpty) + SelectableText( + source.url, + style: ChainTheme.mono(size: 11, color: theme.colorScheme.primary), + ), + const SizedBox(height: ChainSpace.sm), + _ProvenanceRow(label: l.storeDataSourceLicense, value: source.license), + if (source.attribution.isNotEmpty) + _ProvenanceRow( + label: l.storeDataSourceAttribution, + value: source.attribution, + ), + const SizedBox(height: ChainSpace.sm), + Text(l.storeDataSourceNote, style: muted), + const SizedBox(height: ChainSpace.lg), + ], + ); + } +} + +/// Label and value on one line, value selectable so an attribution +/// string can be copied straight into a compliance note. +class _ProvenanceRow extends StatelessWidget { + final String label; + final String value; + + const _ProvenanceRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 150, + child: Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded( + child: SelectableText(value, style: theme.textTheme.bodySmall), + ), + ], + ), + ); + } +} + /// 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 diff --git a/test/store_data_source_test.dart b/test/store_data_source_test.dart new file mode 100644 index 0000000..4f391e0 --- /dev/null +++ b/test/store_data_source_test.dart @@ -0,0 +1,85 @@ +// Guards for the data-provenance block in the store detail sheet. +// +// The rule it protects (docs/architecture/store-format.md): the terms +// of the material a source module fetches are separate from the +// module's own licence, and an operator must see them before the +// install button, not in a README afterwards. + +import 'package:chain_studio/data/hub.dart'; +import 'package:chain_studio/l10n/app_localizations.dart'; +import 'package:chain_studio/pages/store.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Widget _host(Widget child) => MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + locale: const Locale('de'), + home: Scaffold(body: SingleChildScrollView(child: child)), + ); + +void main() { + const bund = DataProvenance( + name: 'gesetze-im-internet.de (BMJ / juris GmbH)', + url: 'https://www.gesetze-im-internet.de/', + license: 'Amtliches Werk (§ 5 UrhG), gemeinfrei', + attribution: 'Quelle: gesetze-im-internet.de (Bundesministerium der Justiz)', + ); + + testWidgets('publisher, terms and attribution are all shown', + (tester) async { + await tester.pumpWidget(_host(const StoreDataSourceSection(source: bund))); + await tester.pumpAndSettle(); + + expect(find.text(bund.name), findsOneWidget); + expect(find.text(bund.url), findsOneWidget); + expect(find.text(bund.license), findsOneWidget); + expect(find.text(bund.attribution), findsOneWidget, + reason: 'an attribution duty must be visible before installing'); + }); + + testWidgets('the attribution row is omitted when none is required', + (tester) async { + const noAttribution = DataProvenance( + name: 'Vom Betreiber gepflegter Metadaten-Katalog', + url: '', + license: 'Katalog des Betreibers', + attribution: '', + ); + await tester + .pumpWidget(_host(const StoreDataSourceSection(source: noAttribution))); + await tester.pumpAndSettle(); + + expect(find.text(noAttribution.name), findsOneWidget); + final l = AppLocalizations.of( + tester.element(find.byType(StoreDataSourceSection)), + )!; + expect(find.text(l.storeDataSourceAttribution), findsNothing, + reason: 'an empty attribution must not render an empty row'); + }); + + testWidgets('the terms are selectable so they can be copied', + (tester) async { + await tester.pumpWidget(_host(const StoreDataSourceSection(source: bund))); + await tester.pumpAndSettle(); + + // Compliance notes get written by copying, not retyping. + expect( + find.byWidgetPredicate( + (w) => w is SelectableText && w.data == bund.attribution, + ), + findsOneWidget, + ); + }); + + testWidgets('the note says whose terms these are', (tester) async { + await tester.pumpWidget(_host(const StoreDataSourceSection(source: bund))); + await tester.pumpAndSettle(); + + final l = AppLocalizations.of( + tester.element(find.byType(StoreDataSourceSection)), + )!; + // Without this line an operator reads the terms as the module's. + expect(find.text(l.storeDataSourceNote), findsOneWidget); + }); +}