diff --git a/mobile/lib/shared/push/push_lease_revocation_outbox.dart b/mobile/lib/shared/push/push_lease_revocation_outbox.dart index 35f9c5ed45..a9a723fa36 100644 --- a/mobile/lib/shared/push/push_lease_revocation_outbox.dart +++ b/mobile/lib/shared/push/push_lease_revocation_outbox.dart @@ -34,7 +34,7 @@ typedef BuzzPushLeaseRevocationWakeScheduler = /// Durable state for retracting one precise NIP-PL lease address. /// /// The signing key is intentionally retained in secure storage only until the -/// relay accepts the tombstone or the old endpoint grant expires. +/// relay accepts the tombstone or the old endpoint grant is known to expire. @immutable class BuzzPushLeaseRevocationRecord { final String relayUrl; @@ -45,7 +45,9 @@ class BuzzPushLeaseRevocationRecord { /// Generation reserved for the next relay attempt. final int generation; - final int expiresAt; + + /// Null when the grant is unavailable and its expiry cannot be established. + final int? expiresAt; final int attemptCount; final int nextAttemptAt; @@ -86,7 +88,9 @@ class BuzzPushLeaseRevocationRecord { if (generation <= 0 || generation > _maxSafeJsonInteger) { throw const FormatException('Push revocation generation is invalid.'); } - if (expiresAt <= 0 || attemptCount < 0 || nextAttemptAt < 0) { + if ((expiresAt != null && expiresAt! <= 0) || + attemptCount < 0 || + nextAttemptAt < 0) { throw const FormatException('Push revocation retry state is invalid.'); } } @@ -145,7 +149,7 @@ class BuzzPushLeaseRevocationRecord { json['nsec'] is! String || json['installationId'] is! String || json['generation'] is! int || - json['expiresAt'] is! int || + (json['expiresAt'] != null && json['expiresAt'] is! int) || json['attemptCount'] is! int || json['nextAttemptAt'] is! int) { throw const FormatException('Invalid push revocation record.'); @@ -157,7 +161,7 @@ class BuzzPushLeaseRevocationRecord { nsec: json['nsec'] as String, installationId: json['installationId'] as String, generation: json['generation'] as int, - expiresAt: json['expiresAt'] as int, + expiresAt: json['expiresAt'] as int?, attemptCount: json['attemptCount'] as int, nextAttemptAt: json['nextAttemptAt'] as int, ); @@ -298,14 +302,15 @@ class BuzzPushLeaseRevocationOutbox { final matching = grants .where((grant) => grant.relayOrigin == relayOrigin) .toList(); - if (matching.length != 1) { + if (matching.length > 1 || + (matching.isEmpty && community.pushLeaseInstallationId == null)) { throw StateError( 'Expected exactly one endpoint grant for push lease revocation.', ); } final currentSeconds = now().millisecondsSinceEpoch ~/ 1000; - final grant = matching.single; - if (grant.expiresAt <= currentSeconds) return false; + final grant = matching.singleOrNull; + if (grant != null && grant.expiresAt <= currentSeconds) return false; if (highestGeneration + 2 > _maxSafeJsonInteger) { throw StateError('Push lease generation is exhausted.'); } @@ -316,9 +321,9 @@ class BuzzPushLeaseRevocationOutbox { memberPubkey: memberPubkey, nsec: nsec, installationId: - community.pushLeaseInstallationId ?? grant.installationId, + community.pushLeaseInstallationId ?? grant!.installationId, generation: highestGeneration + 2, - expiresAt: grant.expiresAt, + expiresAt: grant?.expiresAt, attemptCount: 0, nextAttemptAt: currentSeconds, ), @@ -367,7 +372,10 @@ class BuzzPushLeaseRevocationOutbox { final currentSeconds = now().millisecondsSinceEpoch ~/ 1000; final records = await storage.loadAll(); final active = records - .where((record) => record.expiresAt > currentSeconds) + .where( + (record) => + record.expiresAt == null || record.expiresAt! > currentSeconds, + ) .toList(); final due = active @@ -440,7 +448,11 @@ class BuzzPushLeaseRevocationOutbox { final currentSeconds = now().millisecondsSinceEpoch ~/ 1000; final records = await storage.loadAll(); final active = records - .where((record) => record.expiresAt > currentSeconds) + .where( + (record) => + record.expiresAt == null || + record.expiresAt! > currentSeconds, + ) .toList(); if (active.length != records.length) { await storage.replaceAll(active); @@ -449,7 +461,12 @@ class BuzzPushLeaseRevocationOutbox { return; } final wakeAt = active - .map((record) => min(record.nextAttemptAt, record.expiresAt)) + .map( + (record) => min( + record.nextAttemptAt, + record.expiresAt ?? record.nextAttemptAt, + ), + ) .reduce(min); final delay = Duration(seconds: max(0, wakeAt - currentSeconds)); _cancelWake = scheduleWake(delay, () { diff --git a/mobile/test/shared/push/push_lease_revocation_outbox_test.dart b/mobile/test/shared/push/push_lease_revocation_outbox_test.dart index 12eb9cfe25..68edb55ddc 100644 --- a/mobile/test/shared/push/push_lease_revocation_outbox_test.dart +++ b/mobile/test/shared/push/push_lease_revocation_outbox_test.dart @@ -58,6 +58,48 @@ void main() { expect(records.single.relayUrl, 'https://relay.example/'); }); + test('removal without a grant retains cleanup through restart', () async { + final community = + Community.create( + name: 'Test', + relayUrl: 'wss://relay.example', + nsec: keys.nsec, + ).copyWith( + pubkey: keys.public, + pushNotificationsEnabled: true, + pushSubscriptionState: const BuzzPushLeaseSubscriptionState.desired() + .withReservedGeneration(4), + ); + final first = _outbox( + storage: storage, + clock: clock, + scheduler: scheduler, + publisher: (_) async => throw StateError('offline'), + ); + expect( + await first.enqueueCommunity(community, readGrants: () async => []), + isTrue, + ); + await first.trigger(); + first.dispose(); + final pending = (await storage.loadAll()).single; + expect(pending.installationId, community.pushLeaseInstallationId); + expect(pending.expiresAt, isNull); + clock.seconds = pending.nextAttemptAt; + final published = []; + final restarted = _outbox( + storage: storage, + clock: clock, + scheduler: scheduler, + publisher: (record) async => published.add(record), + ); + await restarted.trigger(); + expect(published.single.installationId, community.pushLeaseInstallationId); + expect(published.single.generation, 7); + expect(await storage.loadAll(), isEmpty); + restarted.dispose(); + }); + test('legacy community revokes its grant-addressed lease', () async { final outbox = _outbox( storage: storage,