mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +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>
580 lines
20 KiB
Dart
580 lines
20 KiB
Dart
import 'dart:convert';
|
|
|
|
/// User-visible Buzz message kinds. This mirrors
|
|
/// `EventKind.channelMessageEventKinds` without importing feature code into
|
|
/// the shared push layer.
|
|
const buzzPushEligibleKinds = [9, 40002, 45001, 45003];
|
|
const buzzPushSelfDirectedKinds = buzzPushEligibleKinds;
|
|
const buzzPushRenderableKinds = buzzPushEligibleKinds;
|
|
const buzzPushChannelKinds = [9];
|
|
const buzzPushChannelChunkSize = 50;
|
|
const buzzPushMaxSubscriptions = 16;
|
|
const buzzPushMaxIgnoreFilters = 8;
|
|
const buzzPushHellthreadParticipantLimit = 20;
|
|
|
|
const _supportedNotificationClasses = {'default'};
|
|
const _filterKeys = {'kinds', 'authors', '#p', '#h', '#e'};
|
|
final _exactHexPattern = RegExp(r'^[0-9a-f]{64}$');
|
|
final _channelIdPattern = RegExp(
|
|
r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$',
|
|
);
|
|
|
|
enum BuzzPushLeaseSubscriptionAuthority { desired, accepted }
|
|
|
|
class BuzzPushFilter {
|
|
final List<int> kinds;
|
|
final List<String>? authors;
|
|
final List<String>? pTags;
|
|
final List<String>? hTags;
|
|
final List<String>? eTags;
|
|
|
|
BuzzPushFilter({
|
|
required Iterable<int> kinds,
|
|
Iterable<String>? authors,
|
|
Iterable<String>? pTags,
|
|
Iterable<String>? hTags,
|
|
Iterable<String>? eTags,
|
|
}) : kinds = List.unmodifiable(kinds),
|
|
authors = _optionalList(authors),
|
|
pTags = _optionalList(pTags),
|
|
hTags = _optionalList(hTags),
|
|
eTags = _optionalList(eTags) {
|
|
_validate();
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'kinds': kinds,
|
|
if (authors != null) 'authors': authors,
|
|
if (pTags != null) '#p': pTags,
|
|
if (hTags != null) '#h': hTags,
|
|
if (eTags != null) '#e': eTags,
|
|
};
|
|
|
|
factory BuzzPushFilter.fromJson(Map<String, dynamic> json) {
|
|
_rejectUnknownKeys(json, _filterKeys, 'push filter');
|
|
return BuzzPushFilter(
|
|
kinds: _intList(json, 'kinds'),
|
|
authors: _optionalStringList(json, 'authors'),
|
|
pTags: _optionalStringList(json, '#p'),
|
|
hTags: _optionalStringList(json, '#h'),
|
|
eTags: _optionalStringList(json, '#e'),
|
|
);
|
|
}
|
|
|
|
void _validate() {
|
|
if (kinds.isEmpty ||
|
|
kinds.any((kind) => !buzzPushEligibleKinds.contains(kind))) {
|
|
throw const FormatException('Push filter contains invalid kinds.');
|
|
}
|
|
for (final value in [...?authors, ...?pTags, ...?eTags]) {
|
|
if (!_exactHexPattern.hasMatch(value)) {
|
|
throw const FormatException(
|
|
'Push filter contains a non-exact hex value.',
|
|
);
|
|
}
|
|
}
|
|
for (final value in hTags ?? const <String>[]) {
|
|
if (!_channelIdPattern.hasMatch(value)) {
|
|
throw const FormatException(
|
|
'Push filter contains an invalid channel ID.',
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
class BuzzPushSuppression {
|
|
final int pTagsMax;
|
|
|
|
const BuzzPushSuppression({required this.pTagsMax}) : assert(pTagsMax > 0);
|
|
|
|
Map<String, dynamic> toJson() => {'p_tags_max': pTagsMax};
|
|
|
|
factory BuzzPushSuppression.fromJson(Map<String, dynamic> json) {
|
|
_rejectUnknownKeys(json, const {'p_tags_max'}, 'push suppression');
|
|
final value = json['p_tags_max'];
|
|
if (value is! int || value <= 0) {
|
|
throw const FormatException('p_tags_max must be a positive integer.');
|
|
}
|
|
return BuzzPushSuppression(pTagsMax: value);
|
|
}
|
|
}
|
|
|
|
class BuzzPushSubscription {
|
|
final BuzzPushFilter filter;
|
|
final String notificationClass;
|
|
final List<BuzzPushFilter> ignore;
|
|
final BuzzPushSuppression? suppress;
|
|
|
|
BuzzPushSubscription({
|
|
required this.filter,
|
|
required this.notificationClass,
|
|
Iterable<BuzzPushFilter> ignore = const [],
|
|
this.suppress,
|
|
}) : ignore = List.unmodifiable(ignore) {
|
|
if (!_supportedNotificationClasses.contains(notificationClass)) {
|
|
throw const FormatException('Unsupported push notification class.');
|
|
}
|
|
if (filter.authors == null &&
|
|
filter.pTags == null &&
|
|
filter.hTags == null) {
|
|
throw const FormatException('Push subscription filter is not narrowed.');
|
|
}
|
|
if (this.ignore.length > buzzPushMaxIgnoreFilters) {
|
|
throw const FormatException(
|
|
'Push subscription has too many ignore filters.',
|
|
);
|
|
}
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'filter': filter.toJson(),
|
|
'class': notificationClass,
|
|
if (ignore.isNotEmpty)
|
|
'ignore': [for (final filter in ignore) filter.toJson()],
|
|
if (suppress != null) 'suppress': suppress!.toJson(),
|
|
};
|
|
|
|
factory BuzzPushSubscription.fromJson(Map<String, dynamic> json) {
|
|
_rejectUnknownKeys(json, const {
|
|
'filter',
|
|
'class',
|
|
'ignore',
|
|
'suppress',
|
|
}, 'push subscription');
|
|
final filter = json['filter'];
|
|
final notificationClass = json['class'];
|
|
final ignore = json['ignore'];
|
|
final suppress = json['suppress'];
|
|
if (filter is! Map || notificationClass is! String) {
|
|
throw const FormatException('Malformed push subscription.');
|
|
}
|
|
if (ignore != null && ignore is! List) {
|
|
throw const FormatException('Push subscription ignore must be a list.');
|
|
}
|
|
if (suppress != null && suppress is! Map) {
|
|
throw const FormatException(
|
|
'Push subscription suppress must be an object.',
|
|
);
|
|
}
|
|
return BuzzPushSubscription(
|
|
filter: BuzzPushFilter.fromJson(Map<String, dynamic>.from(filter)),
|
|
notificationClass: notificationClass,
|
|
ignore: [
|
|
for (final raw in ignore as List<dynamic>? ?? const [])
|
|
if (raw is Map)
|
|
BuzzPushFilter.fromJson(Map<String, dynamic>.from(raw))
|
|
else
|
|
throw const FormatException('Malformed push ignore filter.'),
|
|
],
|
|
suppress: suppress == null
|
|
? null
|
|
: BuzzPushSuppression.fromJson(Map<String, dynamic>.from(suppress)),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Desired and relay-accepted lease subscriptions are intentionally separate.
|
|
/// Keeping both sets makes relay rejection or expiry detectable instead of
|
|
/// assuming the desired lease was accepted unchanged.
|
|
class BuzzPushLeaseSubscriptionState {
|
|
final BuzzPushLeaseSubscriptionAuthority authority;
|
|
final List<BuzzPushSubscription> desired;
|
|
final List<BuzzPushSubscription>? accepted;
|
|
|
|
/// Monotonic generation of the relay-facing kind-30350 lease.
|
|
final int? acceptedGeneration;
|
|
|
|
/// Highest lease generation durably reserved by the client. This advances
|
|
/// before relay publication so a relay commit followed by a local failure
|
|
/// cannot make the next retry reuse a stale generation.
|
|
final int? generationCursor;
|
|
|
|
/// Higher-generation inactive lease that still needs relay acceptance.
|
|
///
|
|
/// This is persisted before publication. Ambiguous retries reserve a newer
|
|
/// generation so a relay-accepted tombstone whose local acknowledgement was
|
|
/// lost is safely superseded without weakening strict relay monotonicity.
|
|
final int? pendingTombstoneGeneration;
|
|
|
|
const BuzzPushLeaseSubscriptionState.desired({
|
|
this.desired = const [],
|
|
this.accepted,
|
|
this.acceptedGeneration,
|
|
this.generationCursor,
|
|
this.pendingTombstoneGeneration,
|
|
}) : assert(
|
|
pendingTombstoneGeneration == null ||
|
|
(pendingTombstoneGeneration > (acceptedGeneration ?? 0) &&
|
|
generationCursor != null &&
|
|
pendingTombstoneGeneration <= generationCursor),
|
|
),
|
|
authority = BuzzPushLeaseSubscriptionAuthority.desired;
|
|
|
|
BuzzPushLeaseSubscriptionState.accepted({
|
|
required Iterable<BuzzPushSubscription> desired,
|
|
required Iterable<BuzzPushSubscription> acceptedSubscriptions,
|
|
required this.acceptedGeneration,
|
|
this.generationCursor,
|
|
this.pendingTombstoneGeneration,
|
|
}) : authority = BuzzPushLeaseSubscriptionAuthority.accepted,
|
|
desired = List.unmodifiable(desired),
|
|
accepted = List.unmodifiable(acceptedSubscriptions) {
|
|
if (acceptedGeneration == null || acceptedGeneration! <= 0) {
|
|
throw const FormatException(
|
|
'Accepted push authority requires a positive lease generation.',
|
|
);
|
|
}
|
|
if (generationCursor != null && generationCursor! < acceptedGeneration!) {
|
|
throw const FormatException(
|
|
'Push lease generation cursor cannot trail the accepted generation.',
|
|
);
|
|
}
|
|
_validatePendingTombstone();
|
|
}
|
|
|
|
void _validatePendingTombstone() {
|
|
final pending = pendingTombstoneGeneration;
|
|
if (pending == null) return;
|
|
if (pending <= (acceptedGeneration ?? 0) ||
|
|
generationCursor == null ||
|
|
pending > generationCursor!) {
|
|
throw const FormatException(
|
|
'Pending push tombstone must be newer than accepted state and durably reserved.',
|
|
);
|
|
}
|
|
}
|
|
|
|
List<BuzzPushSubscription> get authoritative => switch (authority) {
|
|
BuzzPushLeaseSubscriptionAuthority.desired => desired,
|
|
BuzzPushLeaseSubscriptionAuthority.accepted => accepted!,
|
|
};
|
|
|
|
BuzzPushLeaseSubscriptionState withDesired(
|
|
Iterable<BuzzPushSubscription> subscriptions,
|
|
) {
|
|
final updated = List<BuzzPushSubscription>.unmodifiable(subscriptions);
|
|
return switch (authority) {
|
|
BuzzPushLeaseSubscriptionAuthority.desired =>
|
|
BuzzPushLeaseSubscriptionState.desired(
|
|
desired: updated,
|
|
accepted: accepted,
|
|
acceptedGeneration: acceptedGeneration,
|
|
generationCursor: generationCursor,
|
|
pendingTombstoneGeneration: pendingTombstoneGeneration,
|
|
),
|
|
BuzzPushLeaseSubscriptionAuthority.accepted =>
|
|
BuzzPushLeaseSubscriptionState.accepted(
|
|
desired: updated,
|
|
acceptedSubscriptions: accepted!,
|
|
acceptedGeneration: acceptedGeneration,
|
|
generationCursor: generationCursor,
|
|
pendingTombstoneGeneration: pendingTombstoneGeneration,
|
|
),
|
|
};
|
|
}
|
|
|
|
BuzzPushLeaseSubscriptionState withAccepted({
|
|
required Iterable<BuzzPushSubscription> subscriptions,
|
|
required int generation,
|
|
}) => BuzzPushLeaseSubscriptionState.accepted(
|
|
desired: desired,
|
|
acceptedSubscriptions: subscriptions,
|
|
acceptedGeneration: generation,
|
|
generationCursor: generationCursor == null || generation > generationCursor!
|
|
? generation
|
|
: generationCursor,
|
|
pendingTombstoneGeneration:
|
|
pendingTombstoneGeneration != null &&
|
|
generation < pendingTombstoneGeneration!
|
|
? pendingTombstoneGeneration
|
|
: null,
|
|
);
|
|
|
|
BuzzPushLeaseSubscriptionState withReservedGeneration(int generation) {
|
|
if (generation <= (generationCursor ?? acceptedGeneration ?? 0)) {
|
|
throw const FormatException(
|
|
'Reserved push lease generation must advance monotonically.',
|
|
);
|
|
}
|
|
return switch (authority) {
|
|
BuzzPushLeaseSubscriptionAuthority.desired =>
|
|
BuzzPushLeaseSubscriptionState.desired(
|
|
desired: desired,
|
|
accepted: accepted,
|
|
acceptedGeneration: acceptedGeneration,
|
|
generationCursor: generation,
|
|
pendingTombstoneGeneration: pendingTombstoneGeneration,
|
|
),
|
|
BuzzPushLeaseSubscriptionAuthority.accepted =>
|
|
BuzzPushLeaseSubscriptionState.accepted(
|
|
desired: desired,
|
|
acceptedSubscriptions: accepted!,
|
|
acceptedGeneration: acceptedGeneration,
|
|
generationCursor: generation,
|
|
pendingTombstoneGeneration: pendingTombstoneGeneration,
|
|
),
|
|
};
|
|
}
|
|
|
|
BuzzPushLeaseSubscriptionState withPendingTombstone(int generation) {
|
|
if (generation <= (generationCursor ?? acceptedGeneration ?? 0)) {
|
|
throw const FormatException(
|
|
'Pending push tombstone generation must advance monotonically.',
|
|
);
|
|
}
|
|
return BuzzPushLeaseSubscriptionState.desired(
|
|
desired: desired,
|
|
accepted: accepted,
|
|
acceptedGeneration: acceptedGeneration,
|
|
generationCursor: generation,
|
|
pendingTombstoneGeneration: generation,
|
|
);
|
|
}
|
|
|
|
/// Migrates a generation reserved by an older client before the explicit
|
|
/// tombstone journal field existed.
|
|
BuzzPushLeaseSubscriptionState withPendingTombstoneAtCursor() {
|
|
final generation = generationCursor;
|
|
if (generation == null || generation <= (acceptedGeneration ?? 0)) {
|
|
return this;
|
|
}
|
|
return BuzzPushLeaseSubscriptionState.desired(
|
|
desired: desired,
|
|
accepted: accepted,
|
|
acceptedGeneration: acceptedGeneration,
|
|
generationCursor: generation,
|
|
pendingTombstoneGeneration: generation,
|
|
);
|
|
}
|
|
|
|
BuzzPushLeaseSubscriptionState withAcceptedTombstone(int generation) {
|
|
if (generation != pendingTombstoneGeneration ||
|
|
generation < (acceptedGeneration ?? 0)) {
|
|
return this;
|
|
}
|
|
return BuzzPushLeaseSubscriptionState.desired(
|
|
desired: desired,
|
|
acceptedGeneration: generation,
|
|
generationCursor:
|
|
generationCursor == null || generation > generationCursor!
|
|
? generation
|
|
: generationCursor,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'authority': authority.name,
|
|
'desired': [for (final subscription in desired) subscription.toJson()],
|
|
if (accepted != null)
|
|
'accepted': [for (final subscription in accepted!) subscription.toJson()],
|
|
if (acceptedGeneration != null) 'acceptedGeneration': acceptedGeneration,
|
|
if (generationCursor != null) 'generationCursor': generationCursor,
|
|
if (pendingTombstoneGeneration != null)
|
|
'pendingTombstoneGeneration': pendingTombstoneGeneration,
|
|
};
|
|
|
|
factory BuzzPushLeaseSubscriptionState.fromJson(Map<String, dynamic> json) {
|
|
_rejectUnknownKeys(json, const {
|
|
'authority',
|
|
'desired',
|
|
'accepted',
|
|
'acceptedGeneration',
|
|
'generationCursor',
|
|
'pendingTombstoneGeneration',
|
|
}, 'push subscription state');
|
|
final authority = json['authority'];
|
|
final desired = _subscriptionList(
|
|
json['desired'],
|
|
'desired',
|
|
allowEmpty: authority == 'desired',
|
|
);
|
|
final acceptedRaw = json['accepted'];
|
|
final accepted = acceptedRaw == null
|
|
? null
|
|
: _subscriptionList(acceptedRaw, 'accepted');
|
|
final acceptedGeneration = json['acceptedGeneration'];
|
|
final generationCursor = json['generationCursor'];
|
|
final pendingTombstoneGeneration = json['pendingTombstoneGeneration'];
|
|
if (acceptedGeneration != null && acceptedGeneration is! int) {
|
|
throw const FormatException(
|
|
'Accepted push lease generation must be an integer.',
|
|
);
|
|
}
|
|
if (generationCursor != null && generationCursor is! int) {
|
|
throw const FormatException(
|
|
'Push lease generation cursor must be an integer.',
|
|
);
|
|
}
|
|
if (pendingTombstoneGeneration != null &&
|
|
pendingTombstoneGeneration is! int) {
|
|
throw const FormatException(
|
|
'Pending push tombstone generation must be an integer.',
|
|
);
|
|
}
|
|
if (authority == 'desired' &&
|
|
pendingTombstoneGeneration is int &&
|
|
(pendingTombstoneGeneration <= (acceptedGeneration as int? ?? 0) ||
|
|
generationCursor is! int ||
|
|
pendingTombstoneGeneration > generationCursor)) {
|
|
throw const FormatException(
|
|
'Pending push tombstone must be newer than accepted state and durably reserved.',
|
|
);
|
|
}
|
|
return switch (authority) {
|
|
'desired' => BuzzPushLeaseSubscriptionState.desired(
|
|
desired: desired,
|
|
accepted: accepted,
|
|
acceptedGeneration: acceptedGeneration as int?,
|
|
generationCursor: generationCursor as int?,
|
|
pendingTombstoneGeneration: pendingTombstoneGeneration as int?,
|
|
),
|
|
'accepted' when accepted != null && acceptedGeneration is int =>
|
|
BuzzPushLeaseSubscriptionState.accepted(
|
|
desired: desired,
|
|
acceptedSubscriptions: accepted,
|
|
acceptedGeneration: acceptedGeneration,
|
|
generationCursor: generationCursor as int?,
|
|
pendingTombstoneGeneration: pendingTombstoneGeneration as int?,
|
|
),
|
|
'accepted' => throw const FormatException(
|
|
'Accepted push authority requires accepted subscriptions and generations.',
|
|
),
|
|
_ => throw const FormatException('Unknown push subscription authority.'),
|
|
};
|
|
}
|
|
}
|
|
|
|
List<BuzzPushSubscription> buildDesiredBuzzPushSubscriptions({
|
|
required String myPubkey,
|
|
Iterable<String> channelIds = const [],
|
|
Iterable<String> mutedChannelIds = const [],
|
|
}) {
|
|
final normalizedPubkey = myPubkey.toLowerCase();
|
|
if (!_exactHexPattern.hasMatch(normalizedPubkey)) {
|
|
throw const FormatException('Push subscription pubkey must be exact hex.');
|
|
}
|
|
|
|
final normalizedChannelIds = channelIds.map(_normalizeChannelID).toSet();
|
|
final normalizedMuted = mutedChannelIds.map(_normalizeChannelID).toSet();
|
|
final activeMuted =
|
|
normalizedMuted.intersection(normalizedChannelIds).toList()..sort();
|
|
final mutedIgnoreFilters = <BuzzPushFilter>[];
|
|
for (final chunk in _chunks(activeMuted, buzzPushChannelChunkSize)) {
|
|
mutedIgnoreFilters.add(
|
|
BuzzPushFilter(kinds: buzzPushChannelKinds, hTags: chunk),
|
|
);
|
|
}
|
|
if (mutedIgnoreFilters.length + 1 > buzzPushMaxIgnoreFilters) {
|
|
throw const FormatException('Too many muted channels for a push lease.');
|
|
}
|
|
|
|
final selfAuthored = BuzzPushFilter(
|
|
kinds: buzzPushRenderableKinds,
|
|
authors: [normalizedPubkey],
|
|
);
|
|
final ignores = [selfAuthored, ...mutedIgnoreFilters];
|
|
const suppression = BuzzPushSuppression(
|
|
pTagsMax: buzzPushHellthreadParticipantLimit,
|
|
);
|
|
final subscriptions = <BuzzPushSubscription>[
|
|
BuzzPushSubscription(
|
|
filter: BuzzPushFilter(
|
|
kinds: buzzPushSelfDirectedKinds,
|
|
pTags: [normalizedPubkey],
|
|
),
|
|
notificationClass: 'default',
|
|
ignore: ignores,
|
|
suppress: suppression,
|
|
),
|
|
];
|
|
|
|
final channels = normalizedChannelIds.difference(normalizedMuted).toList()
|
|
..sort();
|
|
for (final chunk in _chunks(channels, buzzPushChannelChunkSize)) {
|
|
subscriptions.add(
|
|
BuzzPushSubscription(
|
|
filter: BuzzPushFilter(kinds: buzzPushChannelKinds, hTags: chunk),
|
|
notificationClass: 'default',
|
|
ignore: ignores,
|
|
suppress: suppression,
|
|
),
|
|
);
|
|
}
|
|
if (subscriptions.length > buzzPushMaxSubscriptions) {
|
|
throw const FormatException('Too many channels for a push lease.');
|
|
}
|
|
return List.unmodifiable(subscriptions);
|
|
}
|
|
|
|
String buzzPushSubscriptionsFingerprint(
|
|
List<BuzzPushSubscription> subscriptions,
|
|
) => jsonEncode([
|
|
for (final subscription in subscriptions) subscription.toJson(),
|
|
]);
|
|
|
|
String buzzPushSubscriptionStateFingerprint(
|
|
BuzzPushLeaseSubscriptionState state,
|
|
) => jsonEncode(state.toJson());
|
|
|
|
List<List<String>> _chunks(List<String> values, int size) => [
|
|
for (var offset = 0; offset < values.length; offset += size)
|
|
values.sublist(offset, (offset + size).clamp(0, values.length)),
|
|
];
|
|
|
|
String _normalizeChannelID(String value) {
|
|
final normalized = value.toLowerCase();
|
|
if (!_channelIdPattern.hasMatch(normalized)) {
|
|
throw const FormatException('Push subscription channel ID is invalid.');
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
List<String>? _optionalList(Iterable<String>? values) =>
|
|
values == null ? null : List.unmodifiable(values);
|
|
|
|
List<int> _intList(Map<String, dynamic> json, String key) {
|
|
final raw = json[key];
|
|
if (raw is! List || raw.any((value) => value is! int)) {
|
|
throw FormatException('$key must be an integer list.');
|
|
}
|
|
return raw.cast<int>();
|
|
}
|
|
|
|
List<String>? _optionalStringList(Map<String, dynamic> json, String key) {
|
|
if (!json.containsKey(key)) return null;
|
|
final raw = json[key];
|
|
if (raw is! List || raw.isEmpty || raw.any((value) => value is! String)) {
|
|
throw FormatException('$key must be a non-empty string list.');
|
|
}
|
|
return raw.cast<String>();
|
|
}
|
|
|
|
List<BuzzPushSubscription> _subscriptionList(
|
|
Object? raw,
|
|
String label, {
|
|
bool allowEmpty = false,
|
|
}) {
|
|
if (raw is! List || (!allowEmpty && raw.isEmpty)) {
|
|
throw FormatException('$label subscriptions must be a non-empty list.');
|
|
}
|
|
return [
|
|
for (final item in raw)
|
|
if (item is Map)
|
|
BuzzPushSubscription.fromJson(Map<String, dynamic>.from(item))
|
|
else
|
|
throw FormatException('Malformed $label subscription.'),
|
|
];
|
|
}
|
|
|
|
void _rejectUnknownKeys(
|
|
Map<String, dynamic> json,
|
|
Set<String> allowed,
|
|
String label,
|
|
) {
|
|
final unknown = json.keys.where((key) => !allowed.contains(key));
|
|
if (unknown.isNotEmpty) {
|
|
throw FormatException('$label contains unknown field ${unknown.first}.');
|
|
}
|
|
}
|