mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
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:
committed by
GitHub
parent
09b21cbe63
commit
972e89ac89
@@ -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, () {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user