From 8ef0abfe11d2c9212e13caf2b75c589d8a9f7b44 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Thu, 2 Jul 2026 18:22:21 -0300 Subject: [PATCH] Require explicit AppCatalog relay acceptance before treating an app report as published --- lib/widgets/app_report_sheet.dart | 30 ++++++- .../WORK-016-report-publish-confirmation.md | 39 ++++++++++ test/widgets/app_report_sheet_test.dart | 78 +++++++++++++++++++ 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 spec/work/WORK-016-report-publish-confirmation.md diff --git a/lib/widgets/app_report_sheet.dart b/lib/widgets/app_report_sheet.dart index 12ce2ed..9795bec 100644 --- a/lib/widgets/app_report_sheet.dart +++ b/lib/widgets/app_report_sheet.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/services/notification_service.dart'; +import 'package:zapstore/utils/extensions.dart'; const kMinimumReportDescriptionLength = 20; final _hexIdentifier = RegExp(r'^[0-9a-f]{64}$', caseSensitive: false); @@ -16,6 +17,25 @@ bool canSubmitAppReport({ violationType != null && description.trim().length >= kMinimumReportDescriptionLength; +bool wasAppReportAccepted(PublishResponse response, String reportEventId) => + response.results[reportEventId]?.any((result) => result.accepted) ?? false; + +String appReportPublishFailure(PublishResponse response, String reportEventId) { + final results = response.results[reportEventId]; + if (results == null || results.isEmpty) { + return 'No relay responded to the report. Check your connection and retry.'; + } + + final rejection = results.firstWhere((result) => !result.accepted); + final reason = rejection.message?.trim(); + if (reason == null || reason.isEmpty) { + return 'Relay ${rejection.relayUrl} explicitly rejected the report without ' + 'providing a reason.'; + } + + return 'Relay ${rejection.relayUrl} rejected the report: $reason'; +} + bool canReportApp(App app) => reportableAppEventId(app) != null && _hexIdentifier.hasMatch(app.pubkey); @@ -234,8 +254,14 @@ class AppReportSheet extends HookConsumerWidget { reason: description, ).signWith(signer); - await report.save(); - await report.publish(relays: 'AppCatalog'); + await ref.storage.save({report}); + final response = await ref.storage.publish({ + report, + }, relays: 'AppCatalog'); + if (!wasAppReportAccepted(response, report.id)) { + submissionError.value = appReportPublishFailure(response, report.id); + return; + } if (context.mounted) { Navigator.pop(context); diff --git a/spec/work/WORK-016-report-publish-confirmation.md b/spec/work/WORK-016-report-publish-confirmation.md new file mode 100644 index 0000000..83a34f7 --- /dev/null +++ b/spec/work/WORK-016-report-publish-confirmation.md @@ -0,0 +1,39 @@ +# WORK-016 — Report Publish Confirmation + +**Feature:** FEAT-009-nip56-app-reporting.md +**Status:** Complete + +## Tasks + +- [x] 1. Treat a NIP-56 report as published only after an AppCatalog relay + explicitly accepts its event. + - Files: `lib/widgets/app_report_sheet.dart` +- [x] 2. Preserve the report and expose a retryable failure when no relay + accepts the event, including timeouts and rejections. + - Files: `lib/widgets/app_report_sheet.dart` +- [x] 3. Cover accepted, rejected, and absent relay responses. + - Files: `test/widgets/app_report_sheet_test.dart` +- [x] 4. Display the relay's rejection reason when it responds negatively. + - Files: `lib/widgets/app_report_sheet.dart`, + `test/widgets/app_report_sheet_test.dart` + +## Test Coverage + +| Scenario | Expected | Status | +|----------|----------|--------| +| Relay accepts report event | Success is reported | [x] | +| Relay rejects or times out | Form stays open with retryable error | [x] | +| No response for report event | Form stays open with retryable error | [x] | +| Relay rejection includes a reason | User sees the relay's reason | [x] | +| Relay rejection has no reason | User sees that the relay omitted it | [x] | + +## Decisions + +### 2026-07-10 — Explicit relay acceptance + +**Decision:** The report flow waits for purplebase's publish response and +considers publishing successful only when at least one AppCatalog relay accepts +the signed report event. + +**Rationale:** A timeout or relay rejection is a completed transport response, +not a successful submission. diff --git a/test/widgets/app_report_sheet_test.dart b/test/widgets/app_report_sheet_test.dart index ad6e82e..b54a6b0 100644 --- a/test/widgets/app_report_sheet_test.dart +++ b/test/widgets/app_report_sheet_test.dart @@ -48,4 +48,82 @@ void main() { ); }); }); + + group('wasAppReportAccepted', () { + test('accepts an explicit relay acceptance for the report event', () { + final response = PublishResponse() + ..addEvent( + 'report-id', + relayUrl: 'wss://relay.zapstore.dev', + accepted: true, + ); + + expect(wasAppReportAccepted(response, 'report-id'), isTrue); + }); + + test('rejects a relay rejection or timeout', () { + final response = PublishResponse() + ..addEvent( + 'report-id', + relayUrl: 'wss://relay.zapstore.dev', + accepted: false, + message: 'Timeout', + ); + + expect(wasAppReportAccepted(response, 'report-id'), isFalse); + }); + + test('rejects a response without the report event', () { + final response = PublishResponse() + ..addEvent( + 'another-event-id', + relayUrl: 'wss://relay.zapstore.dev', + accepted: true, + ); + + expect(wasAppReportAccepted(response, 'report-id'), isFalse); + }); + }); + + group('appReportPublishFailure', () { + test('includes the relay rejection reason', () { + final response = PublishResponse() + ..addEvent( + 'report-id', + relayUrl: 'wss://relay.zapstore.dev', + accepted: false, + message: 'rate-limited: slow down chief', + ); + + expect( + appReportPublishFailure(response, 'report-id'), + 'Relay wss://relay.zapstore.dev rejected the report: ' + 'rate-limited: slow down chief', + ); + }); + + test('explains when the report event has no relay response', () { + final response = PublishResponse(); + + expect( + appReportPublishFailure(response, 'report-id'), + 'No relay responded to the report. Check your connection and retry.', + ); + }); + + test('explains when the relay rejects without a reason', () { + final response = PublishResponse() + ..addEvent( + 'report-id', + relayUrl: 'wss://relay.zapstore.dev', + accepted: false, + ); + + expect( + appReportPublishFailure(response, 'report-id'), + 'Relay wss://relay.zapstore.dev explicitly rejected the report without ' + 'providing a reason.', + ); + }); + }); }