mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +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>
601 lines
18 KiB
Dart
601 lines
18 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}$';
|
|
|
|
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('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');
|
|
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,
|
|
'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.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}');
|
|
}
|
|
}
|