Require explicit AppCatalog relay acceptance before treating an app report as published

This commit is contained in:
franzap
2026-07-02 18:22:21 -03:00
parent 8c3b679ce7
commit 8ef0abfe11
3 changed files with 145 additions and 2 deletions
+28 -2
View File
@@ -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);
@@ -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.
+78
View File
@@ -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.',
);
});
});
}