mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
This PR implements MVP, iOS-only,
[NIP-PL](8d2d0ff5ad/docs/nips/NIP-PL.md)-compliant
push notifications.
A relay with `BUZZ_PUSH_ENABLED` will send a push notification for any
message that appears in the in-app Notifications tab.
## Enrollment flow
The first time the client first connects to a relay with
`BUZZ_PUSH_ENABLED`:
```mermaid
sequenceDiagram
autonumber
participant App as Buzz iOS app
participant iOS
participant Relay as Buzz relay
participant Attest as Apple App Attest
participant Gateway as Push gateway
App->>Relay: Fetch NIP-11 push capability
Relay-->>App: Push profile, current relay public key, and limits
par
App->>iOS: Request notification permission
iOS-->>App: Permission result
and
App->>iOS: Register for remote notifications
iOS-->>App: Device token
end
App->>Gateway: Request installation challenge
Gateway-->>App: Single-use challenge
App->>Attest: Attest installation transcript
Attest-->>App: Attestation proof
App->>Gateway: Enroll device token and proof
Gateway-->>App: Installation handle
App->>Gateway: Request delegation challenge
Gateway-->>App: Single-use challenge
App->>Attest: Assert relay-key delegation
Attest-->>App: Assertion
App->>Gateway: Create delegation
Gateway-->>App: Opaque endpoint grant
App->>Relay: Publish encrypted push lease and filters
Relay-->>App: Lease acknowledged
```
## Push-time flow
When a notification-eligible event is received by the relay:
```mermaid
%%{init: {
"sequence": {
"actorMargin": 20,
"width": 110,
"messageMargin": 18,
"diagramMarginX": 8,
"wrap": true
}
}}%%
sequenceDiagram
autonumber
participant Relay as Buzz relay
participant Gateway as Push gateway
participant APNs as Apple Push<br/>Notification service
participant iOS
participant NSE as Notification service<br/>extension
Relay->>Gateway: POST /v1/deliveries/apns<br/>opaque endpoint grant, request ID, expiry, NIP-98 authorization
Gateway->>APNs: POST /3/device/{device-token}<br/>topic, request ID, expiry, constant mutable-content payload
APNs-->>Gateway: 200 OK: request accepted
Gateway-->>Relay: 200 OK: accepted status
APNs-->>iOS: Notification: constant reconnect alert<br/>mutable-content = 1
iOS->>NSE: Invoke extension<br/>original notification content
NSE->>Relay: POST /query: subscription filters, limit 10<br/>NIP-98 authorization
Relay-->>NSE: 200 OK: signed Nostr events<br/>kinds 9, 40002, 45001, or 45003
NSE->>iOS: Complete notification: title, body, subtitle<br/>thread ID, exact-message target
```
relay → push gateway → APNs -> NSE -> Notification Center
## Known limitations
The APNs wake payload is intentionally constant and opaque: it contains
no originating community or message identifier, in keeping with the
implemented NIP-PL privacy design.
The Notification Service Extension must therefore reconnect to the relay
and resolve eligible messages after each wake. Around overlapping wakes,
timing boundaries, or resolution windows, notification presentation may
occasionally omit an expected message or display a message more than
once.
This best-effort behavior is deliberately accepted for the current
implementation and will be measured during the internal rollout to
determine whether the user experience is acceptable before any broader
deployment; the implementation does not claim exactly-once presentation.
## Validation
Live end-to-end hardware validation used an internal remotely hosted
development relay and push gateway, the APNs sandbox, and a physical
iPhone 12 mini:
- A second real Buzz client published a uniquely marked message through
the hosted relay.
- The relay matched the message and sent the constant opaque wake
through the hosted gateway. The gateway made an actual APNs request; no
`simctl push` or simulated notification was used.
- The iPhone received the notification on its lock screen. The
Notification Service Extension reconnected to the relay, fetched the
event, verified its ID and signature, and replaced the placeholder
content with the real notification title and body.
- After the app populated its shared presentation cache, a final marked
notification visibly showed the sender display name, sender avatar, and
hashtag-prefixed channel name.
- Tapping a lock-screen notification opened Buzz and exercised the
notification-response path and navigated to the corresponding message.
Final validation with a dogfood-signed artifact and production App
Attest/APNs configuration remains a release step.
## Independent pre-reviews
- **First pass:**
[Carl](buzz://message?channel=18882f4c-289f-41db-942f-81f6f8066da1&id=74ab9a93bb227f3e762568f1cf9fee66d7495b0edc3918735ff787238b9cc585)
found missing transient retries, executor-key rotation suppression,
duplicate installation renewal, and an unauthenticated challenge write
amplifier. These were resolved by [retry-safe
bootstrap](https://github.com/block/buzz/pull/6269/changes/12c66ea62)
and [authenticated renewal plus a cross-replica
quota](https://github.com/block/buzz/pull/6269/changes/8e5ece0bd).
[sol-max](buzz://message?channel=ad83385f-8e9e-4461-9a35-c1bf2e208532&id=d26d53daa4684669e2ed354638241f13f36c3a97027fe8b4dd738aff09038962)
found delegation generation burning and an edited applied migration,
resolved by [exact-generation
revocation](https://github.com/block/buzz/pull/6269/changes/c26d2159d)
and a [forward-only
migration](https://github.com/block/buzz/pull/6269/changes/956c1d099).
[k3-max](buzz://message?channel=5e46055d-a766-4065-ae25-05d1e4aaa6b2&id=d43139138a0b15f806cbdbeeedd8f69d992cadf2e805876db6fdde6a34c7eda1)
found no blockers.
- **Exact-head re-review:**
[Carl](buzz://message?channel=18882f4c-289f-41db-942f-81f6f8066da1&id=a897721673459301b0cf26e8b85a1478d7ebbb56a4621f93d774c98d395b8f68),
[sol-max](buzz://message?channel=ad83385f-8e9e-4461-9a35-c1bf2e208532&id=fb2159f709ec68f74f7b21459acd76da0e8a7c5c0f3d469f99826b0cc2380849),
and
[k3-max](buzz://message?channel=5e46055d-a766-4065-ae25-05d1e4aaa6b2&id=2ce2842910435f562e9d9cc718595848f281b122c94605e523a4b964254b8bfb)
independently returned **NO BLOCKERS** at `7eb3a650b`; k3-max also
revalidated every remediation and the endpoint-specific App Attest
enrollment bound.
---------
Signed-off-by: Tom Brow <tomb@squareup.com>
Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Jordan Mecom <jm@squareup.com>
554 lines
18 KiB
Dart
554 lines
18 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:nostr/nostr.dart' as nostr;
|
|
|
|
import '../community/community.dart';
|
|
import '../relay/signed_event_relay.dart';
|
|
import 'dev_push_lease.dart';
|
|
import 'push_bridge.dart';
|
|
|
|
const _maxSafeJsonInteger = 9007199254740991;
|
|
const _baseRetryDelay = Duration(seconds: 30);
|
|
const _maximumRetryDelay = Duration(hours: 6);
|
|
const _lowercaseHex64Pattern = r'^[0-9a-f]{64}$';
|
|
const _installationIdPattern = r'^[0-9a-f]{32}$';
|
|
|
|
typedef BuzzPushLeaseRevocationPublisher =
|
|
Future<void> Function(BuzzPushLeaseRevocationRecord record);
|
|
typedef BuzzPushLeaseRevocationClock = DateTime Function();
|
|
typedef BuzzPushLeaseRevocationJitter = double Function();
|
|
typedef BuzzPushLeaseRevocationErrorReporter =
|
|
void Function(Object error, StackTrace stackTrace);
|
|
typedef BuzzPushLeaseRevocationWakeScheduler =
|
|
void Function() Function(Duration delay, void Function() wake);
|
|
|
|
/// 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.
|
|
@immutable
|
|
class BuzzPushLeaseRevocationRecord {
|
|
final String relayUrl;
|
|
final String relayOrigin;
|
|
final String memberPubkey;
|
|
final String nsec;
|
|
final String installationId;
|
|
|
|
/// Generation reserved for the next relay attempt.
|
|
final int generation;
|
|
final int expiresAt;
|
|
final int attemptCount;
|
|
final int nextAttemptAt;
|
|
|
|
BuzzPushLeaseRevocationRecord({
|
|
required this.relayUrl,
|
|
required this.relayOrigin,
|
|
required this.memberPubkey,
|
|
required this.nsec,
|
|
required this.installationId,
|
|
required this.generation,
|
|
required this.expiresAt,
|
|
required this.attemptCount,
|
|
required this.nextAttemptAt,
|
|
}) {
|
|
if (canonicalBuzzPushRelayOrigin(relayUrl) != relayOrigin) {
|
|
throw const FormatException(
|
|
'Push revocation relay URL and origin do not match.',
|
|
);
|
|
}
|
|
if (!RegExp(_lowercaseHex64Pattern).hasMatch(memberPubkey)) {
|
|
throw const FormatException(
|
|
'Push revocation member pubkey must be exact lowercase hex.',
|
|
);
|
|
}
|
|
final decoded = nostr.Nip19.decode(payload: nsec);
|
|
if (decoded.prefix != nostr.Nip19Prefix.nsec ||
|
|
decoded.data.length != 64 ||
|
|
nostr.Keys(decoded.data).public != memberPubkey) {
|
|
throw const FormatException(
|
|
'Push revocation signing key does not match its member pubkey.',
|
|
);
|
|
}
|
|
if (!RegExp(_installationIdPattern).hasMatch(installationId)) {
|
|
throw const FormatException(
|
|
'Push revocation installation id must be exact lowercase hex.',
|
|
);
|
|
}
|
|
if (generation <= 0 || generation > _maxSafeJsonInteger) {
|
|
throw const FormatException('Push revocation generation is invalid.');
|
|
}
|
|
if (expiresAt <= 0 || attemptCount < 0 || nextAttemptAt < 0) {
|
|
throw const FormatException('Push revocation retry state is invalid.');
|
|
}
|
|
}
|
|
|
|
String get leaseAddress => '$memberPubkey|$relayOrigin|$installationId';
|
|
|
|
BuzzPushLeaseRevocationRecord copyWith({
|
|
int? generation,
|
|
int? expiresAt,
|
|
int? attemptCount,
|
|
int? nextAttemptAt,
|
|
}) => BuzzPushLeaseRevocationRecord(
|
|
relayUrl: relayUrl,
|
|
relayOrigin: relayOrigin,
|
|
memberPubkey: memberPubkey,
|
|
nsec: nsec,
|
|
installationId: installationId,
|
|
generation: generation ?? this.generation,
|
|
expiresAt: expiresAt ?? this.expiresAt,
|
|
attemptCount: attemptCount ?? this.attemptCount,
|
|
nextAttemptAt: nextAttemptAt ?? this.nextAttemptAt,
|
|
);
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'version': 1,
|
|
'relayUrl': relayUrl,
|
|
'relayOrigin': relayOrigin,
|
|
'memberPubkey': memberPubkey,
|
|
'nsec': nsec,
|
|
'installationId': installationId,
|
|
'generation': generation,
|
|
'expiresAt': expiresAt,
|
|
'attemptCount': attemptCount,
|
|
'nextAttemptAt': nextAttemptAt,
|
|
};
|
|
|
|
factory BuzzPushLeaseRevocationRecord.fromJson(Map<String, dynamic> json) {
|
|
const keys = {
|
|
'version',
|
|
'relayUrl',
|
|
'relayOrigin',
|
|
'memberPubkey',
|
|
'nsec',
|
|
'installationId',
|
|
'generation',
|
|
'expiresAt',
|
|
'attemptCount',
|
|
'nextAttemptAt',
|
|
};
|
|
if (json.keys.toSet().difference(keys).isNotEmpty ||
|
|
keys.difference(json.keys.toSet()).isNotEmpty ||
|
|
json['version'] != 1 ||
|
|
json['relayUrl'] is! String ||
|
|
json['relayOrigin'] is! String ||
|
|
json['memberPubkey'] is! String ||
|
|
json['nsec'] is! String ||
|
|
json['installationId'] is! String ||
|
|
json['generation'] is! int ||
|
|
json['expiresAt'] is! int ||
|
|
json['attemptCount'] is! int ||
|
|
json['nextAttemptAt'] is! int) {
|
|
throw const FormatException('Invalid push revocation record.');
|
|
}
|
|
return BuzzPushLeaseRevocationRecord(
|
|
relayUrl: json['relayUrl'] as String,
|
|
relayOrigin: json['relayOrigin'] as String,
|
|
memberPubkey: json['memberPubkey'] as String,
|
|
nsec: json['nsec'] as String,
|
|
installationId: json['installationId'] as String,
|
|
generation: json['generation'] as int,
|
|
expiresAt: json['expiresAt'] as int,
|
|
attemptCount: json['attemptCount'] as int,
|
|
nextAttemptAt: json['nextAttemptAt'] as int,
|
|
);
|
|
}
|
|
}
|
|
|
|
class BuzzPushLeaseRevocationStorage {
|
|
static const _key = 'buzz_push_lease_revocations_v1';
|
|
|
|
final FlutterSecureStorage _secure;
|
|
|
|
BuzzPushLeaseRevocationStorage({FlutterSecureStorage? secure})
|
|
: _secure = secure ?? const FlutterSecureStorage();
|
|
|
|
Future<List<BuzzPushLeaseRevocationRecord>> loadAll() async {
|
|
final raw = await _secure.read(key: _key);
|
|
if (raw == null) return <BuzzPushLeaseRevocationRecord>[];
|
|
final decoded = jsonDecode(raw);
|
|
if (decoded is! List<dynamic>) {
|
|
throw const FormatException('Push revocation outbox must be a list.');
|
|
}
|
|
final records = [
|
|
for (final value in decoded)
|
|
BuzzPushLeaseRevocationRecord.fromJson(
|
|
Map<String, dynamic>.from(value as Map),
|
|
),
|
|
];
|
|
final addresses = records.map((record) => record.leaseAddress).toSet();
|
|
if (addresses.length != records.length) {
|
|
throw const FormatException(
|
|
'Push revocation outbox contains duplicate lease addresses.',
|
|
);
|
|
}
|
|
return records;
|
|
}
|
|
|
|
Future<void> replaceAll(
|
|
Iterable<BuzzPushLeaseRevocationRecord> records,
|
|
) async {
|
|
final values = records.toList();
|
|
final addresses = values.map((record) => record.leaseAddress).toSet();
|
|
if (addresses.length != values.length) {
|
|
throw const FormatException(
|
|
'Push revocation outbox contains duplicate lease addresses.',
|
|
);
|
|
}
|
|
if (values.isEmpty) {
|
|
await _secure.delete(key: _key);
|
|
return;
|
|
}
|
|
await _secure.write(
|
|
key: _key,
|
|
value: jsonEncode([for (final record in values) record.toJson()]),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A durable, single-flight retry coordinator for removed-community leases.
|
|
///
|
|
/// Every attempt reserves its successor generation and retry time before
|
|
/// network I/O. A process death during an ambiguous publication therefore
|
|
/// cannot cause an immediate replay or reuse a generation the relay may have
|
|
/// accepted.
|
|
class BuzzPushLeaseRevocationOutbox {
|
|
BuzzPushLeaseRevocationOutbox({
|
|
required this.storage,
|
|
required this.publisher,
|
|
this.now = DateTime.now,
|
|
BuzzPushLeaseRevocationJitter? jitter,
|
|
this.reportError = reportPushLeaseCleanupError,
|
|
BuzzPushLeaseRevocationWakeScheduler? scheduleWake,
|
|
}) : jitter = jitter ?? Random.secure().nextDouble,
|
|
scheduleWake = scheduleWake ?? _scheduleTimer;
|
|
|
|
final BuzzPushLeaseRevocationStorage storage;
|
|
final BuzzPushLeaseRevocationPublisher publisher;
|
|
final BuzzPushLeaseRevocationClock now;
|
|
final BuzzPushLeaseRevocationJitter jitter;
|
|
final BuzzPushLeaseRevocationErrorReporter reportError;
|
|
final BuzzPushLeaseRevocationWakeScheduler scheduleWake;
|
|
|
|
Future<void> _storageTail = Future.value();
|
|
Future<void>? _drain;
|
|
int _wakeGeneration = 0;
|
|
void Function()? _cancelWake;
|
|
bool _started = false;
|
|
bool _disposed = false;
|
|
|
|
Future<T> _serialize<T>(Future<T> Function() operation) {
|
|
final result = _storageTail.then((_) => operation());
|
|
_storageTail = result.then<void>(
|
|
(_) {},
|
|
onError: (Object _, StackTrace _) {},
|
|
);
|
|
return result;
|
|
}
|
|
|
|
Future<void> enqueue(BuzzPushLeaseRevocationRecord record) async {
|
|
await _serialize(() async {
|
|
final records = await storage.loadAll();
|
|
final index = records.indexWhere(
|
|
(candidate) => candidate.leaseAddress == record.leaseAddress,
|
|
);
|
|
if (index >= 0) {
|
|
if (record.generation <= records[index].generation) return;
|
|
records[index] = record;
|
|
} else {
|
|
records.add(record);
|
|
}
|
|
await storage.replaceAll(records);
|
|
});
|
|
if (_started) _scheduleNextWake();
|
|
}
|
|
|
|
Future<bool> enqueueCommunity(
|
|
Community community, {
|
|
Future<List<BuzzPushEndpointGrant>> Function()? readGrants,
|
|
}) async {
|
|
final state = community.pushSubscriptionState;
|
|
final highestGeneration =
|
|
state.generationCursor ?? state.acceptedGeneration;
|
|
if (highestGeneration == null ||
|
|
(!community.pushNotificationsEnabled &&
|
|
state.pendingTombstoneGeneration == null)) {
|
|
return false;
|
|
}
|
|
final nsec = community.nsec;
|
|
if (nsec == null || nsec.isEmpty) {
|
|
throw StateError(
|
|
'Push lease revocation requires a community signing key.',
|
|
);
|
|
}
|
|
final decoded = nostr.Nip19.decode(payload: nsec);
|
|
final memberPubkey = community.pubkey ?? nostr.Keys(decoded.data).public;
|
|
final relayUrl = canonicalBuzzPushRelayHttpUrl(community.relayUrl);
|
|
final relayOrigin = canonicalBuzzPushRelayOrigin(relayUrl);
|
|
final grants = await (readGrants ?? readBuzzPushEndpointGrants)();
|
|
final matching = grants
|
|
.where(
|
|
(grant) =>
|
|
grant.relayOrigin == relayOrigin &&
|
|
grant.appProfile == buzzDevPushAppProfile,
|
|
)
|
|
.toList();
|
|
if (matching.length != 1) {
|
|
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;
|
|
if (highestGeneration + 2 > _maxSafeJsonInteger) {
|
|
throw StateError('Push lease generation is exhausted.');
|
|
}
|
|
await enqueue(
|
|
BuzzPushLeaseRevocationRecord(
|
|
relayUrl: relayUrl,
|
|
relayOrigin: relayOrigin,
|
|
memberPubkey: memberPubkey,
|
|
nsec: nsec,
|
|
installationId:
|
|
community.pushLeaseInstallationId ?? grant.installationId,
|
|
generation: highestGeneration + 2,
|
|
expiresAt: grant.expiresAt,
|
|
attemptCount: 0,
|
|
nextAttemptAt: currentSeconds,
|
|
),
|
|
);
|
|
return true;
|
|
}
|
|
|
|
Future<void> start() async {
|
|
if (_disposed) throw StateError('Push revocation outbox is disposed.');
|
|
if (_started) return trigger();
|
|
_started = true;
|
|
await trigger();
|
|
}
|
|
|
|
Future<void> trigger() {
|
|
if (_disposed) return Future.value();
|
|
final active = _drain;
|
|
if (active != null) return active;
|
|
final drain = _drainDue();
|
|
_drain = drain;
|
|
void finish() {
|
|
if (identical(_drain, drain)) _drain = null;
|
|
if (!_disposed) _scheduleNextWake();
|
|
}
|
|
|
|
drain.then<void>((_) => finish(), onError: (_, _) => finish());
|
|
return drain;
|
|
}
|
|
|
|
Future<void> _drainDue() async {
|
|
while (!_disposed) {
|
|
final record = await _reserveNextDueAttempt();
|
|
if (record == null) return;
|
|
try {
|
|
await publisher(record);
|
|
await _removeAccepted(record);
|
|
pushLeaseCleanupError.value = null;
|
|
} catch (error, stackTrace) {
|
|
reportError(error, stackTrace);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<BuzzPushLeaseRevocationRecord?> _reserveNextDueAttempt() => _serialize(
|
|
() async {
|
|
final currentSeconds = now().millisecondsSinceEpoch ~/ 1000;
|
|
final records = await storage.loadAll();
|
|
final active = records
|
|
.where((record) => record.expiresAt > currentSeconds)
|
|
.toList();
|
|
final due =
|
|
active
|
|
.where((record) => record.nextAttemptAt <= currentSeconds)
|
|
.toList()
|
|
..sort((left, right) {
|
|
final schedule = left.nextAttemptAt.compareTo(
|
|
right.nextAttemptAt,
|
|
);
|
|
return schedule != 0
|
|
? schedule
|
|
: left.leaseAddress.compareTo(right.leaseAddress);
|
|
});
|
|
if (due.isEmpty) {
|
|
if (active.length != records.length) {
|
|
await storage.replaceAll(active);
|
|
}
|
|
return null;
|
|
}
|
|
final record = due.first;
|
|
if (record.generation >= _maxSafeJsonInteger) {
|
|
throw StateError('Push lease generation is exhausted.');
|
|
}
|
|
final reserved = record.copyWith(
|
|
generation: record.generation + 1,
|
|
attemptCount: record.attemptCount + 1,
|
|
nextAttemptAt: currentSeconds + _retryDelaySeconds(record.attemptCount),
|
|
);
|
|
final index = active.indexWhere(
|
|
(candidate) => candidate.leaseAddress == record.leaseAddress,
|
|
);
|
|
if (index < 0) {
|
|
throw StateError('Reserved push revocation record disappeared.');
|
|
}
|
|
active[index] = reserved;
|
|
await storage.replaceAll(active);
|
|
return record;
|
|
},
|
|
);
|
|
|
|
int _retryDelaySeconds(int priorAttempts) {
|
|
final exponent = min(priorAttempts, 20);
|
|
final factor = 1 << exponent;
|
|
final maximumSeconds = min(
|
|
_maximumRetryDelay.inSeconds,
|
|
_baseRetryDelay.inSeconds * factor,
|
|
);
|
|
final half = maximumSeconds ~/ 2;
|
|
return half + (jitter() * (maximumSeconds - half)).floor();
|
|
}
|
|
|
|
Future<void> _removeAccepted(BuzzPushLeaseRevocationRecord attempted) =>
|
|
_serialize(() async {
|
|
final records = await storage.loadAll();
|
|
records.removeWhere(
|
|
(record) =>
|
|
record.leaseAddress == attempted.leaseAddress &&
|
|
record.generation == attempted.generation + 1,
|
|
);
|
|
await storage.replaceAll(records);
|
|
});
|
|
|
|
void _scheduleNextWake() {
|
|
if (!_started || _disposed) return;
|
|
final generation = ++_wakeGeneration;
|
|
_cancelWake?.call();
|
|
_cancelWake = null;
|
|
unawaited(
|
|
_serialize(() async {
|
|
final currentSeconds = now().millisecondsSinceEpoch ~/ 1000;
|
|
final records = await storage.loadAll();
|
|
final active = records
|
|
.where((record) => record.expiresAt > currentSeconds)
|
|
.toList();
|
|
if (active.length != records.length) {
|
|
await storage.replaceAll(active);
|
|
}
|
|
if (active.isEmpty || generation != _wakeGeneration || _disposed) {
|
|
return;
|
|
}
|
|
final wakeAt = active
|
|
.map((record) => min(record.nextAttemptAt, record.expiresAt))
|
|
.reduce(min);
|
|
final delay = Duration(seconds: max(0, wakeAt - currentSeconds));
|
|
_cancelWake = scheduleWake(delay, () {
|
|
if (generation != _wakeGeneration || _disposed) return;
|
|
_cancelWake = null;
|
|
unawaited(trigger());
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
|
|
void dispose() {
|
|
_disposed = true;
|
|
_wakeGeneration += 1;
|
|
_cancelWake?.call();
|
|
_cancelWake = null;
|
|
}
|
|
}
|
|
|
|
void Function() _scheduleTimer(Duration delay, void Function() wake) {
|
|
final timer = Timer(delay, wake);
|
|
return timer.cancel;
|
|
}
|
|
|
|
String canonicalBuzzPushRelayOrigin(String relayUrl) {
|
|
final uri = _buzzPushRelayUri(relayUrl);
|
|
final scheme = switch (uri.scheme) {
|
|
'https' || 'wss' => 'wss',
|
|
'http' || 'ws' => 'ws',
|
|
_ => throw StateError('Validated relay URL has an unsupported scheme.'),
|
|
};
|
|
return '$scheme://${uri.authority}';
|
|
}
|
|
|
|
String canonicalBuzzPushRelayHttpUrl(String relayUrl) {
|
|
final uri = _buzzPushRelayUri(relayUrl);
|
|
final scheme = switch (uri.scheme) {
|
|
'https' || 'wss' => 'https',
|
|
'http' || 'ws' => 'http',
|
|
_ => throw StateError('Validated relay URL has an unsupported scheme.'),
|
|
};
|
|
return uri.replace(scheme: scheme, path: '/').toString();
|
|
}
|
|
|
|
Uri _buzzPushRelayUri(String relayUrl) {
|
|
final uri = Uri.tryParse(relayUrl);
|
|
if (uri == null ||
|
|
!const {'http', 'https', 'ws', 'wss'}.contains(uri.scheme) ||
|
|
uri.host.isEmpty ||
|
|
uri.userInfo.isNotEmpty ||
|
|
(uri.path.isNotEmpty && uri.path != '/') ||
|
|
uri.hasQuery ||
|
|
uri.hasFragment) {
|
|
throw FormatException('Invalid relay URL for push revocation: $relayUrl');
|
|
}
|
|
return uri;
|
|
}
|
|
|
|
Future<void> publishBuzzPushLeaseRevocation(
|
|
BuzzPushLeaseRevocationRecord record,
|
|
) async {
|
|
final descriptor = await fetchBuzzPushLeaseDescriptor(record.relayUrl);
|
|
if (descriptor.origin != record.relayOrigin) {
|
|
throw StateError('Relay push origin changed while revocation was pending.');
|
|
}
|
|
final uri = Uri.parse(record.relayUrl);
|
|
final wsUrl = uri
|
|
.replace(scheme: uri.scheme == 'https' ? 'wss' : 'ws')
|
|
.toString();
|
|
await publishBuzzPushLeaseTombstone(
|
|
descriptor: descriptor,
|
|
installationId: record.installationId,
|
|
generation: record.generation,
|
|
nsec: record.nsec,
|
|
memberPubkey: record.memberPubkey,
|
|
submit: ({required kind, required content, required tags, createdAt}) =>
|
|
submitSignedEventOnce(
|
|
wsUrl: wsUrl,
|
|
nsec: record.nsec,
|
|
kind: kind,
|
|
content: content,
|
|
tags: tags,
|
|
createdAt: createdAt,
|
|
),
|
|
);
|
|
}
|
|
|
|
final buzzPushLeaseRevocationStorageProvider =
|
|
Provider<BuzzPushLeaseRevocationStorage>(
|
|
(ref) => BuzzPushLeaseRevocationStorage(),
|
|
);
|
|
|
|
final buzzPushLeaseRevocationOutboxProvider =
|
|
Provider<BuzzPushLeaseRevocationOutbox>((ref) {
|
|
final outbox = BuzzPushLeaseRevocationOutbox(
|
|
storage: ref.read(buzzPushLeaseRevocationStorageProvider),
|
|
publisher: publishBuzzPushLeaseRevocation,
|
|
);
|
|
ref.onDispose(outbox.dispose);
|
|
return outbox;
|
|
});
|