fix(mobile): allow community removal without a cached push grant (#8978)

## Summary

Fix community removal failing with “Expected exactly one endpoint grant
for push lease revocation” when saved push state exists but the current
build has no cached grant, including builds without a push gateway.

When the community has a saved installation ID, journal its targeted
revocation without requiring the grant. An unknown grant expiry stays
unknown: retain cleanup credentials until the relay accepts the
revocation. Existing known-expiry cleanup and legacy grant-based
addressing remain unchanged.

### Related issue

No matching open issue found. Follows [the durable revocation
outbox](https://github.com/block/buzz/pull/8706).

### Testing

The new regression fails with the reported error on the preceding
implementation. It now verifies missing-grant enqueue, offline retry,
persistence across restart, the exact lease address and eventual cleanup
after acceptance. On a physical iPhone with the fix applied to the
onboarding test build, the previously failing community removal
succeeded.

Unknown-expiry records retain their signing key in secure storage until
successful revocation. Downgrading to a client that requires an integer
expiry will reject those pending records.

Signed-off-by: John Tennant <jtennant@squareup.com>
Co-authored-by: Aaron <f425b86c3b780d43f8d9cf1802429d04dd985bf92eac44fca9468b5ad3887c5e@buzz.block.builderlab.xyz>
This commit is contained in:
John Matthew Tennant
2026-11-02 21:46:49 -05:00
committed by GitHub
parent 09b21cbe63
commit 972e89ac89
2 changed files with 72 additions and 13 deletions

View File

@@ -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, () {

View File

@@ -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 = <BuzzPushLeaseRevocationRecord>[];
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,