feat(studio): show a source module's data terms before the install button
Some checks failed
Security / Security check (push) Failing after 2s

The store detail sheet now carries a data-source block for source.*
modules: publisher, upstream url, the terms in plain words, and any
attribution the operator has to carry with the output. It sits above
maintainers and above the install button, because it is a decision
input rather than a footnote.

The values are selectable: compliance notes get written by copying,
not retyping. A note names whose terms these are, so nobody reads them
as the module's own licence. Four guards, including that an empty
attribution renders no empty row.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-09-09 12:36:45 +02:00
parent fb809a4cbd
commit 35a79d0bb6
8 changed files with 307 additions and 44 deletions

View file

@ -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<String> 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,
});
}

View file

@ -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.",

View file

@ -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.",

View file

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

View file

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

View file

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

View file

@ -253,8 +253,9 @@ class _StorePageState extends State<StorePage> {
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<StorePage> {
// 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<bool>(
items: [
ChainSegmentItem(
@ -381,8 +381,7 @@ class _StorePageState extends State<StorePage> {
),
],
value: _showStudio,
onChanged: (v) =>
setState(() => _showStudio = v),
onChanged: (v) => setState(() => _showStudio = v),
),
),
// ONE page-level notice when the hub says
@ -1695,18 +1694,15 @@ 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,
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<void> _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

View file

@ -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);
});
}