mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
Relay capability additions such as `read_state_snapshot` currently make mobile push discovery reject otherwise valid NIP-11 documents. This blocks descriptor-dependent enrollment, lease updates, and remote revocation. Read unrelated top-level metadata permissively while preserving validation of the advertised `nip-pl` extension and the complete push descriptor. Each returned descriptor value is covered by compatibility tests; missing or malformed required push fields still fail. ### Validation - [Codex code review](https://github.com/block/buzz/pull/8160#issuecomment-6049355700) and [security review](https://github.com/block/buzz/pull/8160#issuecomment-6049486587) completed without findings on the final code. - Added 24 regression cases covering read-state metadata, arbitrary future metadata, HTTP discovery, missing/malformed push fields, and invalid extension advertisements. The six compatibility/discovery cases fail on unchanged main and pass with the fix. - All 55 focused descriptor, bootstrap, capability, and revocation tests pass. Mobile formatting and analysis pass. - Full mobile gate passes: 3,063 tests passed and four skipped with a configured gateway, plus all three tests for an unconfigured gateway build and the gateway recipe checks. - [Current-head CI](https://github.com/block/buzz/actions/runs/37706738403) passes, including Flutter tests, Android debug APK compilation, the complete iOS simulator app, and the unsigned iOS release build. - Production HTTP descriptor discovery against live personal-relay metadata passes without enrollment or relay changes. - Device enrollment, delivery, notification tap, and opt-out remain to be exercised in the updated iOS candidate. No relay or device state was changed. buzz-review-completed --------- Signed-off-by: Tom Brow <tomb@block.xyz>
632 lines
19 KiB
Dart
632 lines
19 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 buzzDevPushAppProfile = 'buzz-ios-dogfood';
|
|
const buzzPushTransport = 'apns';
|
|
const _maxSafeJsonInteger = 9007199254740991;
|
|
const _maxLeaseLifetimeSeconds = 2592000;
|
|
const _lowercaseHex64Pattern = r'^[0-9a-f]{64}$';
|
|
const _installationIdPattern = r'^[0-9a-f]{32}$';
|
|
|
|
class BuzzPushLeaseDescriptor {
|
|
final String origin;
|
|
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,
|
|
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('nip-pl')) {
|
|
throw const FormatException('NIP-11 does not advertise nip-pl');
|
|
}
|
|
|
|
final push = _stringMap(information['push'], name: 'push');
|
|
_requireExactKeys(
|
|
push,
|
|
required: const {
|
|
'origin',
|
|
'keys',
|
|
'app_profiles',
|
|
'push_kinds',
|
|
'h_grammar',
|
|
'class_support',
|
|
'limitation',
|
|
},
|
|
allowed: const {
|
|
'origin',
|
|
'keys',
|
|
'app_profiles',
|
|
'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;
|
|
|
|
final profiles = _mapList(push['app_profiles'], name: 'app_profiles');
|
|
final profileIds = <String>{};
|
|
String? transport;
|
|
for (final profile in profiles) {
|
|
_requireExactKeys(
|
|
profile,
|
|
required: const {'id', 'transport'},
|
|
allowed: const {'id', 'transport'},
|
|
name: 'app profile',
|
|
);
|
|
final id = _nonEmptyString(profile['id'], name: 'app profile id');
|
|
if (!profileIds.add(id)) {
|
|
throw FormatException('Duplicate app profile id: $id');
|
|
}
|
|
final candidate = _nonEmptyString(
|
|
profile['transport'],
|
|
name: 'app profile transport',
|
|
);
|
|
if (id == buzzDevPushAppProfile) transport = candidate;
|
|
}
|
|
if (transport != buzzPushTransport) {
|
|
throw const FormatException(
|
|
'NIP-11 does not advertise the dogfood APNs profile',
|
|
);
|
|
}
|
|
|
|
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');
|
|
if (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,
|
|
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,
|
|
);
|
|
}
|
|
}
|
|
|
|
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 {
|
|
_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 plaintextMap = <String, dynamic>{
|
|
'v': 1,
|
|
'origin': descriptor.origin,
|
|
'app_profile': grant.appProfile,
|
|
'transport': descriptor.transport,
|
|
'endpoint': grant.endpointGrant,
|
|
'generation': effectiveLeaseGeneration,
|
|
'active': true,
|
|
'subscriptions': [
|
|
for (final subscription in subscriptions) subscription.toJson(),
|
|
],
|
|
};
|
|
final plaintext = jsonEncode(plaintextMap);
|
|
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.appProfile != buzzDevPushAppProfile) {
|
|
throw const FormatException('Endpoint grant is not for buzz-ios-dogfood');
|
|
}
|
|
if (grant.endpointGrant.isEmpty ||
|
|
utf8.encode(grant.endpointGrant).length > descriptor.maxEndpointLength) {
|
|
throw const FormatException(
|
|
'Endpoint grant violates the advertised endpoint limit',
|
|
);
|
|
}
|
|
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}');
|
|
}
|
|
}
|