mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
Buzz push currently selects an application profile, such as `dogfood`, during client registration. We want each gateway to serve one Apple app configured by the operator instead. This removes profile selection from the client, relay, and gateway, while allowing internal and public gateways to use their own Apple identities. This PR implements that model in the Buzz source and upstream deployment chart. Discovery, enrollment, grants, and leases no longer carry an application-profile selector. Each gateway has one configured Apple application and APNs transport. The protocol changes are incompatible with the old registration format, so relays advertise `buzz-push-v1` instead of `nip-pl`. Old clients do not discover the new push capability. Updated clients use separate Keychain records and register again. The [push notes](https://github.com/block/buzz/blob/push-capability-v1/docs/buzz-push.md) describe the experimental contract. Landing this PR is the first step. Publish the matching gateway image and upstream chart, then land the [shared chart update](https://github.com/squareup/cash-helm-charts/pull/5740) and [gateway deployment update](https://github.com/squareup/builderbot-platform-core-infrastructure/pull/1085). Before cutover, stop old gateway instances and check legacy registration state. Migrations refuse to proceed if old gateway installations or active relay leases remain; they do not delete that state. Physical-device verification remains necessary before production activation. ### Validation Gateway and focused Flutter push tests pass, as do the Swift package, simulator build, and native notification/enrichment tests. A local relay advertises `buzz-push-v1` without `nip-pl` or `app_profiles`. The PostgreSQL suite passed 868 tests; its two migration-test failures were corrected and pass a focused rerun together with the gateway cutover guard. Chart rendering and release-contract checks pass. Desktop JavaScript tests, desktop and web builds, native desktop compilation, mobile analysis/tests, and desktop native tests pass. The full Rust unit lane and prescribed `just test` command pass, including workspace integration tests against task-isolated local services. The production overlay now requires an explicitly supplied APNs topic, with omission and rendered-value regression coverage. The corrected Swift checksum assertion passes the complete package suite: 78 XCTest tests and 42 Swift Testing tests. No physical-device enrollment or production cutover is claimed. Local merge validation: the embedded migration test and formatting pass after reserving profile migration 0060 behind rollback migration 0059. The local pre-push Rust lane failed ACP timing/steering tests (`idle_resets_on_stdout_activity`, `keepalive_resets_idle_past_deadline`, and the failed/missing-outcome steering tests). ACP and its dependency manifests are unchanged by this branch; all four pass in an isolated single-thread rerun. The documented local-check exception skips only the Rust pre-push lane for publication; required CI remains mandatory. Mobile and desktop pre-push lanes passed. Combined migration validation: after rollback 0059 landed, the main merge preserves profile migration 0060 and both embedded migration assertions. The focused migration test and formatting pass. The next local push again failed only the unchanged ACP idle/keepalive timing tests (1053 passed, 2 failed, 3 ignored); other lanes passed. Publication uses the same documented local Rust-lane exception; required CI remains mandatory. Follow-up review fix: removed the legacy profile column from the copied-lease fixture. All four event-follow-up contract tests pass on isolated PostgreSQL databases, covering desired and migrated schemas in dual and app-only modes. Formatting passes. Publication retains the prior passing local-lane evidence and uses the authorized pre-push exception for the known unrelated ACP timing failures; required CI remains mandatory. --------- Signed-off-by: Tom Brow <tomb@block.xyz>
550 lines
18 KiB
Dart
550 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)
|
|
.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;
|
|
});
|