Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions mobile/lib/shared/relay/relay_auth_discovery.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import 'dart:async';
import 'dart:convert';

import 'package:http/http.dart' as http;

/// Host policy, independent of whether a relay supports optional NIP-FI.
enum RelayAuthRequirement { legacy, optional, required }

/// Protocol compatibility is separate from host policy. It does not establish
/// a trusted sign-in origin or prove this build is configured for the host.
typedef RelayAuthDiscovery = ({
RelayAuthRequirement requirement,
bool compatible,
});

/// Reads explicit policy even when this client cannot use the auth descriptor.
/// Missing fields retain legacy behavior; malformed policy remains a failure.
RelayAuthDiscovery parseRelayAuthDiscovery(Object? info) {
if (info is! Map) throw const FormatException('NIP-11 is not an object');
final RelayAuthRequirement requirement;
if (!info.containsKey('requires_nip_fi')) {
requirement = RelayAuthRequirement.legacy;
} else {
final required = info['requires_nip_fi'];
if (required is! bool) {
throw const FormatException('Invalid NIP-FI requirement');
}
requirement = required
? RelayAuthRequirement.required
: RelayAuthRequirement.optional;
}
final limitation = info['limitation'];
final federation = info['federated_identity'];
final freshness = federation is Map
? federation['assertion_freshness']
: null;
return (
requirement: requirement,
compatible:
limitation is Map &&
limitation['federated_identity'] == true &&
federation is Map &&
federation['core'] == 'client-attached' &&
freshness is Map &&
freshness['class'] == 'offline-jwt',
);
}

/// A failed discovery request is distinct from a successfully discovered policy.
class RelayAuthDiscoveryFailure implements Exception {
const RelayAuthDiscoveryFailure(this.status);

final int status;

@override
String toString() =>
'Community authentication discovery failed (HTTP $status)';
}

/// Reads this host's public policy without credentials, redirects or caching.
/// The caller owns [client]; one deadline bounds headers and the entire body.
Future<RelayAuthDiscovery> fetchRelayAuthDiscovery(
http.Client client,
Uri uri, {
Duration deadline = const Duration(seconds: 10),
}) async {
if (!['http', 'https'].contains(uri.scheme) ||
uri.host.isEmpty ||
uri.userInfo.isNotEmpty ||
uri.hasQuery ||
uri.hasFragment ||
!['', '/', '/info'].contains(uri.path)) {
throw const FormatException('Expected a public community discovery URL');
}
final abort = Completer<void>();
final request = http.AbortableRequest('GET', uri, abortTrigger: abort.future)
..followRedirects = false
..headers['Accept'] = 'application/nostr+json';
StreamIterator<List<int>>? body;
Future<RelayAuthDiscovery> read() async {
final response = await client.send(request);
final iterator = body = StreamIterator(response.stream);
if (response.statusCode != 200) {
throw RelayAuthDiscoveryFailure(response.statusCode);
}
final bytes = <int>[];
while (await iterator.moveNext()) {
if (bytes.length + iterator.current.length > 256 * 1024) {
throw const FormatException('NIP-11 exceeds size limit');
}
bytes.addAll(iterator.current);
}
return parseRelayAuthDiscovery(jsonDecode(utf8.decode(bytes)));
}

try {
return await read().timeout(deadline);
} finally {
if (!abort.isCompleted) abort.complete();
unawaited(body?.cancel());
}
}
69 changes: 69 additions & 0 deletions mobile/test/fixtures/nip_fi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Community authentication discovery fixtures

These are complete `/info` response bodies emitted by the production HTTP router
at [BUZZ-511 commit d3ae4935b11e207f0736833cd5220f207be359fd](https://github.com/block/buzz/commit/d3ae4935b11e207f0736833cd5220f207be359fd).
The normative field is documented in that commit's
[NIP-FI Discovery section](https://github.com/block/buzz/blob/d3ae4935b11e207f0736833cd5220f207be359fd/docs/nips/NIP-FI.md#discovery).

| Fixture suffix | Global mode | Synthetic Host | Required | Protocol compatible |
| --- | --- | --- | --- | --- |
| `required` | off | `required.example` | true | true |
| `optional` | off | `free.example` | false | false |
| `optional_capable` | shadow | `free.example` | false | true |

The export reused `nip_fi_policy::tests::fixture` from that commit and called
`router::build_router` through `tower::ServiceExt::oneshot` for both `/` and
`/info`, with `Host` and `Accept: application/nostr+json`. It asserted HTTP 200,
`Cache-Control: no-store`, and equal decoded documents for the two routes, then
saved the unmodified `/info` body. Policy and verifier inputs are synthetic test
boundaries; discovery serialization and routing are the production implementation.
This is a wire fixture, not deployed-host or device acceptance evidence.

To regenerate, use a detached checkout of the source commit and append this
temporary test to `crates/buzz-relay/src/nip_fi_policy/tests.rs`:

```rust
#[tokio::test]
async fn export_buzz_517_discovery_fixtures() {
let out = std::path::PathBuf::from(std::env::var("BUZZ_FIXTURE_OUT").unwrap());
std::fs::create_dir_all(&out).unwrap();
for (name, mode, host) in [
("required", NipFiMode::Off, "required.example"),
("optional", NipFiMode::Off, "free.example"),
("optional_capable", NipFiMode::Shadow, "free.example"),
] {
let (state, _, _, _) = fixture(mode).await;
let app = crate::router::build_router(state);
let mut documents = Vec::new();
for path in ["/", "/info"] {
let response = app.clone().oneshot(
Request::get(path)
.header(header::HOST, host)
.header(header::ACCEPT, "application/nostr+json")
.body(Body::empty()).unwrap()
).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
let bytes = to_bytes(response.into_body(), 256 * 1024).await.unwrap();
documents.push(serde_json::from_slice::<serde_json::Value>(&bytes).unwrap());
if path == "/info" {
std::fs::write(out.join(format!("{name}.json")), &bytes).unwrap();
}
}
assert_eq!(documents[0], documents[1]);
}
}
```

From that checkout, run with an absolute output directory:

```sh
BUZZ_FIXTURE_OUT=/tmp/buzz-517-discovery cargo test -p buzz-relay --lib nip_fi_policy::tests::export_buzz_517_discovery_fixtures -- --exact
```

Copy the three output JSON bodies here with the source-commit filename prefix.
The temporary exporter does not modify the relay implementation.

Legacy relays lacking `requires_nip_fi` remain covered separately by the parser's
absent-field cases. Capability alone must never turn an optional host into a
required one. These fixtures do not configure or authorize any sign-in origin.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"requires_nip_fi":false,"name":"Buzz Relay","description":"Buzz — private team communication relay","artifacts":{"count":"/count","max_filters":1,"max_offset":10000,"max_page_size":1000,"max_predicates":32,"max_tag_bytes":65536,"max_tag_name_bytes":128,"max_tag_value_bytes":4096,"max_tags":256,"max_values":256,"modes":["current","history","head"],"query":"/query","query_timeout_ms":2000,"removal_kind":45011,"revision_kind":45010,"sync":{"max_events":4096,"version":1},"version":1,"websocket_query_extensions":true},"pubkey":null,"contact":null,"supported_nips":[1,2,10,11,16,17,23,25,29,33,38,42,50,56],"supported_extensions":["nip-er","nip-ar","nip-cl","buzz-search-sort","buzz-unlisted-channels"],"software":"https://github.com/block/buzz","version":"0.2.1","limitation":{"max_message_length":524288,"max_subscriptions":1024,"max_filters":10,"max_limit":1000,"max_subid_length":256,"min_pow_difficulty":null,"auth_required":true,"payment_required":false,"restricted_writes":true,"due_delivery_mode":"push","max_not_before_delta":31536000}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"requires_nip_fi":false,"name":"Buzz Relay","description":"Buzz — private team communication relay","artifacts":{"count":"/count","max_filters":1,"max_offset":10000,"max_page_size":1000,"max_predicates":32,"max_tag_bytes":65536,"max_tag_name_bytes":128,"max_tag_value_bytes":4096,"max_tags":256,"max_values":256,"modes":["current","history","head"],"query":"/query","query_timeout_ms":2000,"removal_kind":45011,"revision_kind":45010,"sync":{"max_events":4096,"version":1},"version":1,"websocket_query_extensions":true},"pubkey":null,"contact":null,"supported_nips":[1,2,10,11,16,17,23,25,29,33,38,42,50,56],"supported_extensions":["nip-er","nip-ar","nip-cl","buzz-search-sort","buzz-unlisted-channels"],"software":"https://github.com/block/buzz","version":"0.2.1","limitation":{"max_message_length":524288,"max_subscriptions":1024,"max_filters":10,"max_limit":1000,"max_subid_length":256,"min_pow_difficulty":null,"auth_required":true,"payment_required":false,"restricted_writes":true,"federated_identity":true,"due_delivery_mode":"push","max_not_before_delta":31536000},"federated_identity":{"assertion_freshness":{"class":"offline-jwt","maximum_residual_upstream_revocation_seconds":null},"core":"client-attached"}}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"requires_nip_fi":true,"name":"Buzz Relay","description":"Buzz — private team communication relay","artifacts":{"count":"/count","max_filters":1,"max_offset":10000,"max_page_size":1000,"max_predicates":32,"max_tag_bytes":65536,"max_tag_name_bytes":128,"max_tag_value_bytes":4096,"max_tags":256,"max_values":256,"modes":["current","history","head"],"query":"/query","query_timeout_ms":2000,"removal_kind":45011,"revision_kind":45010,"sync":{"max_events":4096,"version":1},"version":1,"websocket_query_extensions":true},"pubkey":null,"contact":null,"supported_nips":[1,2,10,11,16,17,23,25,29,33,38,42,50,56],"supported_extensions":["nip-er","nip-ar","nip-cl","buzz-search-sort","buzz-unlisted-channels"],"software":"https://github.com/block/buzz","version":"0.2.1","limitation":{"max_message_length":524288,"max_subscriptions":1024,"max_filters":10,"max_limit":1000,"max_subid_length":256,"min_pow_difficulty":null,"auth_required":true,"payment_required":false,"restricted_writes":true,"federated_identity":true,"due_delivery_mode":"push","max_not_before_delta":31536000},"federated_identity":{"assertion_freshness":{"class":"offline-jwt","maximum_residual_upstream_revocation_seconds":null},"core":"client-attached"}}
Loading
Loading