Files
buzz/mobile/lib/shared/push/dev_push_lease.dart
Tom Brow bad9d26142 fix(mobile): push every message in your own Solo chats (#8615)
## Summary

Mobile push only covered Solo chats through explicit mentions. Solo
chats are private streams rather than DMs, and the push lease subscribed
channel-wide only to DMs. Agent replies in your own Solo chats therefore
never woke the phone.

The lease now subscribes channel-wide to the viewer's own joined,
unarchived Solo chats, using the same predicate as the Solo tab,
including older unlabelled Solo chats listed in the encrypted Solo
record. Self-authored messages stay excluded through the existing lease
ignores. Muted Solo chats stay out of the lease, like muted channels, so
they push only on mention. The lease waits while the Solo record loads
so an unresolved record never drops legacy Solo chats. If that record
fails with no cached copy, DM and mention updates proceed without the
legacy chats, which the Solo tab cannot list either, and they return
when it recovers. Solo chats created by someone else still push only on
mention, matching how they appear in the channel list.

A lease is bounded by its channel count (750), by the relay's advertised
plaintext and content sizes, and by NIP-44's 65535-byte plaintext
ceiling. DMs always stay, as before. Solo chats fill the remaining room
most recent first, sized with the publisher's own lease encoding, the
exact NIP-44 payload length and the largest endpoint the relay accepts.
Sizes are computed rather than built, so a relay's advertised limits
never drive an allocation. Solo chats beyond the room push only on
mention instead of failing the lease update or publication, and
Notifications settings says how many. A relay too small even for DMs and
mentions fails push setup visibly instead of saving a lease it would
reject. Publication accepts only endpoint grants in the gateway's
JSON-safe alphabet, so the sizing assumption holds.

Thread replies outside DMs still do not push unless they mention you.
That is a separate gap.

### Related issue

None found.

### Validation

- Added test: the desired lease includes joined DMs and the viewer's own
Solo chats, and includes legacy Solo chats from the Solo record, and
excludes archived and muted Solo chats, other people's Solo chats, and
unlabelled private streams. It fails without the fix.
- Added test: the sync provider holds the desired lease while the Solo
record loads, proceeds without legacy chats if the record fails, and
adds them once it arrives. It fails if loading is treated as empty or if
a failure blocks updates.
- Added tests: over the channel count, and over each size limit (relay
plaintext, relay content, NIP-44 plaintext), the lease keeps every DM
and exactly the newest Solo chats, reports the rest as omitted, and is
accepted by `publishBuzzDevPushLease` with a grant at the relay's
endpoint limit, while a max-length grant that JSON would escape is
rejected as not a gateway grant. A relay advertising a 2^50-byte
endpoint limit is sized without allocating. Each row fails if its guard
is removed.
- Added test: a relay whose content or plaintext limit cannot fit even
the lease without Solo chats rejects that lease at publication, and the
provider fails setup instead of saving it. It fails if the guard is
removed.
- Added test: the NIP-44 payload length matches real encryption at every
padding boundary up to 65535 bytes.
- Added test: Notifications settings shows "Older Solo chats notify only
on mention" with the omitted count. It fails without the row.
- Mobile lint and the full mobile test suite pass.
- Not exercised end to end on a device. A signed dev build was installed
on a test iPhone against a personal dev relay, but the phone's dev
identity owns no assistant there, so the device could not create a Solo
chat without exporting its key. This will be validated in dogfood. The
relay matcher already handles channel-wide `#h` leases for DMs, and Solo
messages use the same kind and scoping.

buzz-review-completed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 16:44:59 -07:00

677 lines
21 KiB
Dart

import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:nostr/nostr.dart' as nostr;
import '../crypto/nip44.dart';
import '../relay/nostr_models.dart';
import '../relay/signed_event_relay.dart';
import 'push_bridge.dart';
import 'push_subscription.dart';
const buzzPushLeaseKind = 30350;
const buzzPushTransport = 'apns';
const _maxSafeJsonInteger = 9007199254740991;
const _maxLeaseLifetimeSeconds = 2592000;
const _lowercaseHex64Pattern = r'^[0-9a-f]{64}$';
const _installationIdPattern = r'^[0-9a-f]{32}$';
// The gateway's `<key id>.<base64url>` grant grammar, which JSON never escapes.
const _endpointGrantPattern = r'^[A-Za-z0-9_.-]+$';
/// Whether an active lease carrying [subscriptions] fits [descriptor]'s
/// plaintext and content limits with any endpoint grant and generation it
/// accepts. Sizes are computed, not built, since the limits come from the relay.
bool buzzPushSubscriptionsFitLease(
BuzzPushLeaseDescriptor descriptor,
List<BuzzPushSubscription> subscriptions,
) {
final withoutEndpoint = utf8
.encode(
_activeLeasePlaintext(
descriptor: descriptor,
endpoint: '',
generation: _maxSafeJsonInteger,
subscriptions: subscriptions,
),
)
.length;
// Publication accepts only grants JSON never escapes, so one at the endpoint
// limit adds exactly it.
final plaintext = withoutEndpoint + descriptor.maxEndpointLength;
final content = nip44PayloadLength(plaintext);
return plaintext <= descriptor.maxPlaintextLength &&
content != null &&
content <= descriptor.maxContentLength;
}
String _activeLeasePlaintext({
required BuzzPushLeaseDescriptor descriptor,
required String endpoint,
required int generation,
required List<BuzzPushSubscription> subscriptions,
}) => jsonEncode(<String, dynamic>{
'v': 1,
'origin': descriptor.origin,
'transport': descriptor.transport,
'endpoint': endpoint,
'generation': generation,
'active': true,
'subscriptions': [
for (final subscription in subscriptions) subscription.toJson(),
],
});
class BuzzPushLeaseDescriptor {
final String origin;
/// Canonical channel-ID grammar advertised by this relay.
final String hGrammar;
final String executorKeyId;
final String executorPubkey;
final String transport;
final int maxLeaseTtlSeconds;
final int maxContentLength;
final int maxPlaintextLength;
final int maxEndpointLength;
final int maxStringLength;
const BuzzPushLeaseDescriptor({
required this.origin,
this.hGrammar = 'uuid-v4-lowercase',
required this.executorKeyId,
required this.executorPubkey,
required this.transport,
required this.maxLeaseTtlSeconds,
required this.maxContentLength,
required this.maxPlaintextLength,
required this.maxEndpointLength,
required this.maxStringLength,
});
factory BuzzPushLeaseDescriptor.fromRelayInformation(
Map<String, dynamic> information,
) {
final extensions = _stringList(
information['supported_extensions'],
name: 'supported_extensions',
);
if (!extensions.contains('buzz-push-v1')) {
throw const FormatException('NIP-11 does not advertise buzz-push-v1');
}
final push = _stringMap(information['push'], name: 'push');
_requireExactKeys(
push,
required: const {
'origin',
'keys',
'push_kinds',
'h_grammar',
'class_support',
'limitation',
},
allowed: const {
'origin',
'keys',
'push_kinds',
'h_grammar',
'class_support',
'limitation',
},
name: 'push descriptor',
);
final origin = _canonicalOrigin(push['origin']);
final keys = _mapList(push['keys'], name: 'push.keys');
final keyIds = <String>{};
final currentKeys = <Map<String, dynamic>>[];
for (final key in keys) {
_requireExactKeys(
key,
required: const {'id', 'pubkey'},
allowed: const {'id', 'pubkey', 'current', 'retiring'},
name: 'push key',
);
final id = _nonEmptyString(key['id'], name: 'push key id');
if (!keyIds.add(id)) {
throw FormatException('Duplicate push key id: $id');
}
_lowercaseHex64(key['pubkey'], name: 'push key pubkey');
final current = key['current'];
final retiring = key['retiring'];
if (current != null && current is! bool) {
throw const FormatException('push key current must be a boolean');
}
if (retiring != null && retiring is! bool) {
throw const FormatException('push key retiring must be a boolean');
}
if (current == true) currentKeys.add(key);
}
if (currentKeys.length != 1) {
throw const FormatException(
'push descriptor must contain exactly one current key',
);
}
final currentKey = currentKeys.single;
const transport = buzzPushTransport;
final pushKinds = _intList(push['push_kinds'], name: 'push_kinds');
if (!buzzPushEligibleKinds.every(pushKinds.contains)) {
throw const FormatException(
'NIP-11 does not advertise every Buzz message kind for push',
);
}
final hGrammar = _nonEmptyString(push['h_grammar'], name: 'h_grammar');
// Retain the advertised grammar so publication can enforce legacy limits.
if (hGrammar != 'uuid-lowercase' && hGrammar != 'uuid-v4-lowercase') {
throw const FormatException('Unsupported push h_grammar');
}
final classSupport = _stringMap(
push['class_support'],
name: 'class_support',
);
final supportedClasses = _stringList(
classSupport[buzzPushTransport],
name: 'class_support.apns',
);
const knownClasses = {'default'};
if (supportedClasses.any((value) => !knownClasses.contains(value))) {
throw const FormatException('class_support contains an unknown class');
}
if (!supportedClasses.contains('default')) {
throw const FormatException('APNs does not support the default class');
}
final limitation = _stringMap(push['limitation'], name: 'limitation');
_requireExactKeys(
limitation,
required: const {
'max_lease_ttl',
'max_leases_per_pubkey',
'max_subscriptions_per_lease',
'max_kinds',
'max_authors',
'max_h',
'max_tag_values',
'max_ignore',
'max_content_len',
'max_plaintext_len',
'max_endpoint_len',
'max_string_len',
},
allowed: const {
'max_lease_ttl',
'max_leases_per_pubkey',
'max_subscriptions_per_lease',
'max_kinds',
'max_authors',
'max_h',
'max_tag_values',
'max_ignore',
'max_content_len',
'max_plaintext_len',
'max_endpoint_len',
'max_string_len',
},
name: 'push limitation',
);
for (final entry in limitation.entries) {
_positiveInt(entry.value, name: 'limitation.${entry.key}');
}
final maxStringLength = limitation['max_string_len'] as int;
_checkStringLength(origin, maxStringLength, name: 'origin');
_checkStringLength(
currentKey['id'] as String,
maxStringLength,
name: 'push key id',
);
final maxLeaseTtl = limitation['max_lease_ttl'] as int;
if (maxLeaseTtl > _maxLeaseLifetimeSeconds) {
throw const FormatException('max_lease_ttl exceeds the NIP-PL v1 limit');
}
return BuzzPushLeaseDescriptor(
origin: origin,
hGrammar: hGrammar,
executorKeyId: currentKey['id'] as String,
executorPubkey: currentKey['pubkey'] as String,
transport: transport,
maxLeaseTtlSeconds: maxLeaseTtl,
maxContentLength: limitation['max_content_len'] as int,
maxPlaintextLength: limitation['max_plaintext_len'] as int,
maxEndpointLength: limitation['max_endpoint_len'] as int,
maxStringLength: maxStringLength,
);
}
}
/// The desired lease requires channel IDs that this relay cannot accept yet.
class BuzzPushChannelGrammarException implements Exception {
const BuzzPushChannelGrammarException();
@override
String toString() =>
'Push lease requires uuid-lowercase support from the relay';
}
/// Validate both positive and ignore filters without weakening mute semantics.
void validateBuzzPushChannelGrammar(
BuzzPushLeaseDescriptor descriptor,
List<BuzzPushSubscription> subscriptions,
) {
if (descriptor.hGrammar == 'uuid-lowercase') return;
for (final subscription in subscriptions) {
for (final filter in [subscription.filter, ...subscription.ignore]) {
for (final channel in filter.hTags ?? const <String>[]) {
// The legacy Rust validator checked version 4, not the UUID variant.
if (channel[14] != '4') {
throw const BuzzPushChannelGrammarException();
}
}
}
}
}
Future<BuzzPushLeaseDescriptor> fetchBuzzPushLeaseDescriptor(
String relayBaseUrl, {
http.Client? client,
Duration timeout = const Duration(seconds: 8),
}) async {
final uri = Uri.tryParse(relayBaseUrl);
if (uri == null ||
!const {'http', 'https'}.contains(uri.scheme) ||
uri.host.isEmpty) {
throw FormatException('Invalid relay HTTP URL: $relayBaseUrl');
}
final requestUri = uri.resolve('/');
final ownedClient = client ?? http.Client();
try {
final response = await ownedClient
.get(requestUri, headers: const {'Accept': 'application/nostr+json'})
.timeout(timeout);
if (response.statusCode != 200) {
throw StateError(
'NIP-11 request failed with HTTP ${response.statusCode}: ${response.body}',
);
}
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('NIP-11 response must be a JSON object');
}
return BuzzPushLeaseDescriptor.fromRelayInformation(decoded);
} finally {
if (client == null) ownedClient.close();
}
}
class BuzzPushLeasePublication {
final String eventId;
final int expiration;
final String plaintext;
const BuzzPushLeasePublication({
required this.eventId,
required this.expiration,
required this.plaintext,
});
}
typedef BuzzPushLeaseSubmit =
Future<NostrEvent> Function({
required int kind,
required String content,
required List<List<String>> tags,
int? createdAt,
});
Future<BuzzPushLeasePublication> publishBuzzDevPushLease({
required BuzzPushEndpointGrant grant,
String? leaseInstallationId,
int? leaseGeneration,
required BuzzPushLeaseDescriptor descriptor,
required String nsec,
required String memberPubkey,
required List<BuzzPushSubscription> subscriptions,
required BuzzPushLeaseSubmit submit,
DateTime Function() now = DateTime.now,
}) async {
validateBuzzPushChannelGrammar(descriptor, subscriptions);
_validateGrant(grant, descriptor);
final effectiveLeaseInstallationId =
leaseInstallationId ?? grant.installationId;
if (!RegExp(_installationIdPattern).hasMatch(effectiveLeaseInstallationId)) {
throw const FormatException(
'Lease installation id must be 16 random bytes encoded as lowercase hex',
);
}
final effectiveLeaseGeneration = leaseGeneration ?? grant.generation;
if (effectiveLeaseGeneration <= 0 ||
effectiveLeaseGeneration > _maxSafeJsonInteger) {
throw const FormatException('Lease generation is invalid');
}
final normalizedMemberPubkey = _lowercaseHex64(
memberPubkey,
name: 'member pubkey',
);
final decoded = nostr.Nip19.decode(payload: nsec);
if (decoded.prefix != nostr.Nip19Prefix.nsec || decoded.data.length != 64) {
throw const FormatException('Signing key must be a 32-byte nsec');
}
final signingPubkey = nostr.Keys(decoded.data).public;
if (signingPubkey != normalizedMemberPubkey) {
throw const FormatException(
'Authenticated signing key does not match the lease member pubkey',
);
}
final nowSeconds = now().millisecondsSinceEpoch ~/ 1000;
final expiration = min(
grant.expiresAt,
nowSeconds + descriptor.maxLeaseTtlSeconds,
);
if (expiration <= nowSeconds) {
throw const FormatException('Endpoint grant is already expired');
}
final plaintext = _activeLeasePlaintext(
descriptor: descriptor,
endpoint: grant.endpointGrant,
generation: effectiveLeaseGeneration,
subscriptions: subscriptions,
);
if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) {
throw const FormatException('lease plaintext exceeds the advertised limit');
}
final conversationKey = getConversationKey(
decoded.data,
descriptor.executorPubkey,
);
final content = nip44Encrypt(conversationKey, plaintext);
if (utf8.encode(content).length > descriptor.maxContentLength) {
throw const FormatException(
'lease ciphertext exceeds the advertised limit',
);
}
final acknowledged = await submit(
kind: buzzPushLeaseKind,
content: content,
tags: [
['d', effectiveLeaseInstallationId],
['expiration', '$expiration'],
['exec', descriptor.executorKeyId],
],
createdAt: nowSeconds,
);
if (acknowledged.id.isEmpty) {
throw StateError('Relay returned an empty event id for the push lease');
}
return BuzzPushLeasePublication(
eventId: acknowledged.id,
expiration: expiration,
plaintext: plaintext,
);
}
Future<BuzzPushLeasePublication> publishBuzzDevPushLeaseThroughRelay({
required BuzzPushEndpointGrant grant,
String? leaseInstallationId,
int? leaseGeneration,
required BuzzPushLeaseDescriptor descriptor,
required String nsec,
required String memberPubkey,
required List<BuzzPushSubscription> subscriptions,
required SignedEventRelay relay,
DateTime Function() now = DateTime.now,
}) => publishBuzzDevPushLease(
grant: grant,
leaseInstallationId: leaseInstallationId,
leaseGeneration: leaseGeneration,
descriptor: descriptor,
nsec: nsec,
memberPubkey: memberPubkey,
subscriptions: subscriptions,
submit: relay.submit,
now: now,
);
/// Publishes the minimal higher-generation inactive lease used when a
/// community is removed from this device. Gateway delegation is intentionally
/// untouched because it is scoped to the installation and relay key, not the
/// community.
Future<BuzzPushLeasePublication> publishBuzzPushLeaseTombstone({
required BuzzPushLeaseDescriptor descriptor,
required String installationId,
required int generation,
required String nsec,
required String memberPubkey,
required BuzzPushLeaseSubmit submit,
DateTime Function() now = DateTime.now,
}) async {
if (!RegExp(_installationIdPattern).hasMatch(installationId)) {
throw const FormatException(
'Installation id must be 16 random bytes encoded as lowercase hex',
);
}
if (generation <= 0 || generation > _maxSafeJsonInteger) {
throw const FormatException('Tombstone generation is invalid');
}
final normalizedMemberPubkey = _lowercaseHex64(
memberPubkey,
name: 'member pubkey',
);
final decoded = nostr.Nip19.decode(payload: nsec);
if (decoded.prefix != nostr.Nip19Prefix.nsec || decoded.data.length != 64) {
throw const FormatException('Signing key must be a 32-byte nsec');
}
if (nostr.Keys(decoded.data).public != normalizedMemberPubkey) {
throw const FormatException(
'Authenticated signing key does not match the lease member pubkey',
);
}
final nowSeconds = now().millisecondsSinceEpoch ~/ 1000;
final expiration = nowSeconds + descriptor.maxLeaseTtlSeconds;
final plaintextMap = <String, dynamic>{
'v': 1,
'origin': descriptor.origin,
'generation': generation,
'active': false,
};
final plaintext = jsonEncode(plaintextMap);
if (utf8.encode(plaintext).length > descriptor.maxPlaintextLength) {
throw const FormatException('lease plaintext exceeds the advertised limit');
}
final content = nip44Encrypt(
getConversationKey(decoded.data, descriptor.executorPubkey),
plaintext,
);
if (utf8.encode(content).length > descriptor.maxContentLength) {
throw const FormatException(
'lease ciphertext exceeds the advertised limit',
);
}
final acknowledged = await submit(
kind: buzzPushLeaseKind,
content: content,
tags: [
['d', installationId],
['expiration', '$expiration'],
['exec', descriptor.executorKeyId],
],
createdAt: nowSeconds,
);
if (acknowledged.id.isEmpty) {
throw StateError('Relay returned an empty event id for the push tombstone');
}
return BuzzPushLeasePublication(
eventId: acknowledged.id,
expiration: expiration,
plaintext: plaintext,
);
}
Future<BuzzPushLeasePublication> publishBuzzPushLeaseTombstoneThroughRelay({
required BuzzPushLeaseDescriptor descriptor,
required String installationId,
required int generation,
required String nsec,
required String memberPubkey,
required SignedEventRelay relay,
DateTime Function() now = DateTime.now,
}) => publishBuzzPushLeaseTombstone(
descriptor: descriptor,
installationId: installationId,
generation: generation,
nsec: nsec,
memberPubkey: memberPubkey,
submit: relay.submit,
now: now,
);
void _validateGrant(
BuzzPushEndpointGrant grant,
BuzzPushLeaseDescriptor descriptor,
) {
if (utf8.encode(grant.installationId).length > 64 ||
!RegExp(_installationIdPattern).hasMatch(grant.installationId)) {
throw const FormatException(
'Installation id must be 16 random bytes encoded as lowercase hex',
);
}
if (grant.relayPubkey != descriptor.executorPubkey) {
throw const FormatException(
'Stored endpoint grant is delegated to a different relay key',
);
}
if (grant.endpointGrant.isEmpty ||
utf8.encode(grant.endpointGrant).length > descriptor.maxEndpointLength) {
throw const FormatException(
'Endpoint grant violates the advertised endpoint limit',
);
}
if (!RegExp(_endpointGrantPattern).hasMatch(grant.endpointGrant)) {
throw const FormatException('Endpoint grant is not a gateway grant');
}
if (grant.endpointEpoch <= 0) {
throw const FormatException('Endpoint grant epoch is invalid');
}
if (grant.generation <= 0 || grant.generation > _maxSafeJsonInteger) {
throw const FormatException('Endpoint grant generation is invalid');
}
}
String _canonicalOrigin(Object? value) {
final origin = _nonEmptyString(value, name: 'origin');
final uri = Uri.tryParse(origin);
if (uri == null ||
!const {'ws', 'wss'}.contains(uri.scheme) ||
uri.host.isEmpty ||
uri.userInfo.isNotEmpty ||
uri.path.isNotEmpty ||
uri.hasQuery ||
uri.hasFragment ||
'${uri.scheme}://${uri.authority}' != origin) {
throw FormatException('Invalid canonical push origin: $origin');
}
return origin;
}
void _checkStringLength(String value, int maximum, {required String name}) {
if (maximum <= 0 || utf8.encode(value).length > maximum) {
throw FormatException('$name exceeds its advertised byte limit');
}
}
String _lowercaseHex64(Object? value, {required String name}) {
final text = _nonEmptyString(value, name: name);
if (!RegExp(_lowercaseHex64Pattern).hasMatch(text)) {
throw FormatException('$name must be exactly 64 lowercase hex characters');
}
return text;
}
String _nonEmptyString(Object? value, {required String name}) {
if (value is! String || value.isEmpty) {
throw FormatException('$name must be a non-empty string');
}
return value;
}
int _positiveInt(Object? value, {required String name}) {
if (value is! int || value <= 0) {
throw FormatException('$name must be a positive integer');
}
return value;
}
Map<String, dynamic> _stringMap(Object? value, {required String name}) {
if (value is! Map<String, dynamic>) {
throw FormatException('$name must be an object');
}
return value;
}
List<Map<String, dynamic>> _mapList(Object? value, {required String name}) {
if (value is! List || value.isEmpty) {
throw FormatException('$name must be a non-empty array');
}
return [
for (final item in value)
if (item is Map<String, dynamic>)
item
else
throw FormatException('$name entries must be objects'),
];
}
List<String> _stringList(Object? value, {required String name}) {
if (value is! List || value.isEmpty) {
throw FormatException('$name must be a non-empty string array');
}
return [
for (final item in value)
if (item is String && item.isNotEmpty)
item
else
throw FormatException('$name entries must be non-empty strings'),
];
}
List<int> _intList(
Object? value, {
required String name,
bool allowEmpty = false,
}) {
if (value is! List || (!allowEmpty && value.isEmpty)) {
throw FormatException(
'$name must be ${allowEmpty ? 'an' : 'a non-empty'} integer array',
);
}
return [
for (final item in value)
if (item is int && item >= 0)
item
else
throw FormatException('$name entries must be non-negative integers'),
];
}
void _requireExactKeys(
Map<String, dynamic> value, {
required Set<String> required,
required Set<String> allowed,
required String name,
}) {
final missing = required.difference(value.keys.toSet());
if (missing.isNotEmpty) {
throw FormatException('$name is missing ${missing.join(', ')}');
}
final unexpected = value.keys.toSet().difference(allowed);
if (unexpected.isNotEmpty) {
throw FormatException('$name contains unknown field ${unexpected.first}');
}
}