mirror of
https://github.com/block/buzz.git
synced 2026-11-02 15:31:27 +00:00
## Summary
When the user-deletion worker dropped a lease heartbeat, its pooled
connection could be left inside an open transaction. The worker's final
lease `release` then reported success but never committed, and a rerun
saw the request as "leased by another worker" for up to 60 s. The
heartbeat is now one autocommit `UPDATE`.
The PR also fixes six PostgreSQL-lane test flakes from the 21–28 Oct
2026 CI audit. It moves one relay test off the 100 ms-acquire test
helper before that test starts flaking the same way.
This is one of seven PRs split out of the `flakey-tests` branch
(siblings below). It touches only `buzz-db`, `buzz-deletion` and
`buzz-relay`. Every relay file except `connection.rs` and
`boot_lifecycle.rs` changes only for the helper rename in the last row.
### Related issue
None found. Nothing on `main` (eda26b942) or in an open PR covers these.
G, a single hermit `pgschema` 504 that needs a workflow change, is out
of scope.
### Flaky tests fixed
| Test | Failing runs | Cause | Change |
|---|---|---|---|
| `buzz-deletion`
`user::worker::postgres_tests::dummy_executor_failure_retries_same_community_stage`
(:47) and `next_step_advances_one_handler_at_a_time_through_completion`
(:131) | 40439041234 (main), 40399635380#3, 40413732817#1 | **Product
bug.** `run_stage_with_heartbeat` drops the renewal future when the
stage handler wins `select!`. The heartbeat opened a transaction. sqlx
0.9 bumps `transaction_depth` only after `BEGIN` returns, so a `begin()`
dropped mid-`BEGIN` queues no `ROLLBACK`, and the connection goes back
to the pool inside an open transaction. The worker's autocommit
`release` then ran inside it and never committed. |
`EntityDeletionStore::heartbeat` is one autocommit `UPDATE`, guarded by
`lock_owned`'s ownership/status/expiry predicate. A superseded lease is
never renewed. A new regression test,
`dropped_heartbeat_leaves_no_open_transaction_for_the_release`, drops
the real heartbeat mid-`BEGIN` through a pausing proxy. |
| `buzz-db`
`session_statement_timeouts_bound_writer_and_reader_sessions`
(runtime/tests.rs:3138, `PoolTimedOut`) | 40128463987, 40314950864#1,
40374286094 (main), 40394821009#1, 40413732817#1 | Each config's lazy
reader pool dialled cold inside the 150 ms `READER_ACQUIRE_TIMEOUT`.
`Db::new` never runs the boot ping. | Warm the reader the way the relay
boots it (`read_pool_boot_ping_for_tests` + `await_idle_floor`). |
| `buzz-relay` `startup_owns_the_dependency_sampler` (boot_lifecycle.rs)
| Torn scrape: 39865872891 (main), 39624074244#2, 40385799750#1,
40413293801#1. Early exit: 39704588623 | metrics-exporter-prometheus
0.18.3 snapshots counters, then gauges. The scrape that first shows the
completion gauge can therefore predate the `checks_total` counters. |
Assert on a second scrape, taken after the gauge is seen. On an early
relay exit, the helper now panics with the exit status, stdout and
stderr. The early exit itself was not reproduced. |
| `buzz-db`
`sample_writer_waits_for_shared_floor_lock_before_sampling_time`
(replica_fence.rs:1086) and
`replica_floor_probe_waits_for_shared_writer_and_records_after_release`
(runtime/tests.rs:3452) | 39601763602 (main), 40391445284#1,
39825846222, 40301667418 | Both scan cluster-wide `pg_stat_activity` but
ran outside the serialized `postgres-cluster-global` group. Connection
churn from other tests masked activity (`MaskedActivity { masked: 1 }`).
| Rename both with a `cluster_global_` prefix. `nextest show-config
test-groups --profile postgres-ci` lists both in the group. |
| `buzz-db`
`stalled_reader_sessions_release_their_slots_for_new_connections`
(reader_pool_postgres_tests.rs:354) | 40399635380#2 | The floor warms
one connection, so the second warm-up acquire dials cold inside 150 ms.
| Retry only that warm-up acquire on `PoolTimedOut`, for up to 5 s
(#8472's precedent). The stall under test keeps the production budget. |
| `buzz-db`
`readiness_check_cancellation_balances_waiter_and_inflight_connection`
(runtime/tests.rs:391, `Elapsed`) | 39785370353 | The test aborted
`pg_sleep(5)` after a fixed 25 ms. Recovery lands at server start + 5 s,
but the bound is abort + 5 s, a ~22 ms margin locally. | Poll
`pg_stat_activity` until the query is active, then abort. Use
`pg_sleep(1)` within the unchanged 5 s bound. |
| `buzz-relay` `upgrade_would_deny_line_is_written_at_auth_while_open`
(connection.rs) | None yet (preventive, added by #8634) | It used the
100 ms-acquire `test_state_with_database_url` against the real test
database, the same shape as the `PoolTimedOut` flakes above. | Use
`test_state_with_database_url_and_pool_options` with sqlx's default
timeout. The 100 ms helper is renamed
`test_state_with_unreachable_database_url`; all its remaining callers
use closed ports. |
Local before/after counts are from the original `flakey-tests` commit
(1f0451915), on macOS with OrbStack Postgres:
- Regression test: 3/3 FAIL before, 3/3 PASS after.
- Worker stress: 3 failures in 288 iterations before; 2,100 passing runs
after.
- Statement timeouts: 44/50 FAIL before, 50/50 PASS after.
- Sampler: 2/200 FAIL before, 200/200 PASS after.
- `MaskedActivity` under `pgbench -C`: 2/50 iterations FAIL before.
After, the lane passed 3× (613/613) but was not re-run under pgbench.
- Stalled reader: 10/30 FAIL before, 30/30 PASS after.
- Forced late-`Execute` cancellation: 10/10 FAIL before, 10/10 PASS
after.
### Verification
This branch was pushed without local re-verification, by the user's
choice: local test runs were overloading the machine and failing for
that reason. **Hosted CI is the check.** It was pushed with `git push
--no-verify`, because the pre-push hook runs clippy and unit tests.
- Every file on this branch is byte-identical to its final version on
`flakey-tests` (67185df6a), where the counts above were measured.
- Not re-run for this PR: clippy, nextest and the Postgres lanes.
- Checked for this PR: `git diff --check`, `rustfmt --check` on the
changed files, and a conflict-free merge with all six siblings onto
`main`.
### Readiness
This PR has a product fix (the `buzz-db` lease heartbeat). Under
AGENTS.md "Before opening a PR", it needs an agent review and a **human
check against a local relay** before `buzz-review-completed` is added.
Suggested check:
1. With local Postgres and a relay, submit and approve a user deletion
with `buzz-admin user-deletions submit` / `approve`.
2. Run `buzz-admin user-deletions run` until it completes, then run it
again at once.
3. Working looks like this: both runs complete, neither reports the
request as leased by another worker, and `buzz-admin user-deletions
inspect <id>` shows no leftover lease. The original race is rare and is
not expected to reproduce by hand; this check confirms releases still
commit.
### Merge order
Independent of the other six. It merges cleanly onto `main`, and with
every sibling, in any order.
### Sibling PRs
Split from `flakey-tests` (one PR per area): #8677 (relay E2E helper +
Windows shell), #8678 (mobile), #8679 (Tauri apt cache + Windows Rust
cap), #8680 (desktop product races), #8681 (desktop E2E state gates),
#8682 (desktop E2E composer + row actions).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
926 lines
36 KiB
Rust
926 lines
36 KiB
Rust
//! NIP-11 relay information document.
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
#[cfg(test)]
|
|
use crate::config::DEFAULT_MAX_FRAME_BYTES;
|
|
|
|
/// NIPs unconditionally supported by this relay, advertised in the NIP-11
|
|
/// document. Kept as a module-level constant so tests can verify it without
|
|
/// constructing a full `Config` (which reads env vars and races with
|
|
/// config.rs tests).
|
|
///
|
|
/// NIP-43 (relay membership) is advertised separately by [`RelayInfo::build`]
|
|
/// only when membership enforcement is actually enabled — see that function.
|
|
pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 50, 56];
|
|
|
|
/// NIP-43 (relay membership). Advertised only when the relay actually
|
|
/// enforces membership (`BUZZ_REQUIRE_RELAY_MEMBERSHIP=true`) AND has a
|
|
/// stable signing key — both are required for kind 13534/8000/8001 events
|
|
/// to be verifiable by clients.
|
|
pub(crate) const NIP_RELAY_MEMBERSHIP: u32 = 43;
|
|
|
|
/// Relay information document served at `GET /` with `Accept: application/nostr+json`.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RelayInfo {
|
|
/// Human-readable relay name.
|
|
pub name: String,
|
|
/// Human-readable relay description.
|
|
pub description: String,
|
|
/// Workspace icon URL (NIP-11 `icon`), per-community, set by relay
|
|
/// admins/owners via the kind:9033 command. Omitted when no icon is set.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub icon: Option<String>,
|
|
/// Host-bound atomic read-state snapshot capability; absent on unresolved hosts.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub read_state_snapshot: Option<serde_json::Value>,
|
|
/// Opt-in, host-bound private accessory API; separate from legacy NIP-RS.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub buzz_v1: Option<BuzzV1Descriptor>,
|
|
/// NIP-AR artifact query transport and enforced resource limits.
|
|
pub artifacts: serde_json::Value,
|
|
/// Relay operator's public key (hex), if published.
|
|
pub pubkey: Option<String>,
|
|
/// Contact address for the relay operator.
|
|
pub contact: Option<String>,
|
|
/// NIPs supported by this relay.
|
|
pub supported_nips: Vec<u32>,
|
|
/// Draft/extension protocol identifiers supported by this relay.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub supported_extensions: Option<Vec<String>>,
|
|
/// NIP-PL executor descriptor. Present only when push delivery is configured.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub push: Option<serde_json::Value>,
|
|
/// URL of the relay software repository.
|
|
pub software: String,
|
|
/// Relay software version string.
|
|
pub version: String,
|
|
/// Protocol and resource limits advertised to clients.
|
|
pub limitation: Option<RelayLimitation>,
|
|
/// Public WebSocket URL of the dedicated NIP-AB device-pairing relay.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub pairing_relay_url: Option<String>,
|
|
/// Canonical origin (`scheme://host[:port]`, no path) of the deployment
|
|
/// admin API, advertised only when the admin surface is configured
|
|
/// (`config.admin.is_some()`). Lets desktop auto-discover the admin
|
|
/// console instead of requiring manual URL entry. Scheme follows the same
|
|
/// loopback rule as NIP-98 `u`-tag verification (see
|
|
/// [`crate::api::admin::admin_api_origin`]).
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub admin_api: Option<String>,
|
|
/// Relay-owned GIF search integration. The descriptor is public and
|
|
/// provider-agnostic; provider credentials remain server-side.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub gif: Option<GifDescriptor>,
|
|
/// Relay's own signing pubkey (NIP-11 `self` field, NIP-43).
|
|
#[serde(rename = "self", skip_serializing_if = "Option::is_none")]
|
|
pub relay_self: Option<String>,
|
|
/// NIP-FI federated identity capability descriptor.
|
|
/// Absent when the relay is in `Off` mode. [FI-TRACE-DISCOVERY-PRIVATE]
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub federated_identity: Option<serde_json::Value>,
|
|
}
|
|
|
|
/// Private read-state accessory contract and enforced client request limits.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct BuzzV1Descriptor {
|
|
/// Accessory contract version, not a Nostr protocol number.
|
|
pub version: u32,
|
|
/// Relay-relative API prefix; callers retain the requesting origin.
|
|
pub base_path: String,
|
|
/// Author-time unread horizon; does not expire messages or frontiers.
|
|
pub retention_seconds: u32,
|
|
/// Maximum joined channels per sidebar page.
|
|
pub max_channels: usize,
|
|
/// Maximum independent write intents per request.
|
|
pub max_intents: usize,
|
|
/// Message kinds that count as unread and as latest activity. Clients
|
|
/// classify live arrivals with this set instead of keeping a copy.
|
|
pub eligible_kinds: [i32; 4],
|
|
}
|
|
|
|
/// Public capability descriptor for relay-proxied GIF search.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
pub struct GifDescriptor {
|
|
/// Provider identifier understood by Buzz clients.
|
|
pub provider: String,
|
|
/// Relay-relative authenticated metadata search endpoint.
|
|
pub search: String,
|
|
/// Relay-relative authenticated share-reporting endpoint.
|
|
pub share: String,
|
|
}
|
|
|
|
/// Protocol and resource limits advertised in the NIP-11 document.
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct RelayLimitation {
|
|
/// Maximum WebSocket frame size in bytes.
|
|
pub max_message_length: Option<u64>,
|
|
/// Maximum number of concurrent subscriptions per connection.
|
|
pub max_subscriptions: Option<u32>,
|
|
/// Maximum number of filters per subscription.
|
|
pub max_filters: Option<u32>,
|
|
/// Maximum value of the `limit` field in a filter.
|
|
pub max_limit: Option<u32>,
|
|
/// Maximum length of a subscription ID string.
|
|
pub max_subid_length: Option<u32>,
|
|
/// Minimum proof-of-work difficulty required for events.
|
|
pub min_pow_difficulty: Option<u32>,
|
|
/// Whether NIP-42 authentication is required before subscribing or
|
|
/// publishing events.
|
|
pub auth_required: bool,
|
|
/// Whether payment is required to use the relay.
|
|
pub payment_required: bool,
|
|
/// Whether writes are restricted to authorized pubkeys.
|
|
pub restricted_writes: bool,
|
|
/// Whether the relay supports NIP-FI federated identity assertions.
|
|
/// Advertised `true` in `Enforce` and `Shadow` mode, so clients attach
|
|
/// evidence; in `Shadow` the relay evaluates it without requiring it.
|
|
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
|
pub federated_identity: bool,
|
|
/// NIP-ER: how the relay delivers due reminders ("push" or "lazy").
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub due_delivery_mode: Option<String>,
|
|
/// NIP-ER: maximum allowed `not_before` horizon in seconds from now.
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub max_not_before_delta: Option<u64>,
|
|
}
|
|
|
|
/// Canonical `RelayLimitation` advertised by this relay.
|
|
///
|
|
/// `max_limit` is [`buzz_db::DEFAULT_MAX_PAGE_LIMIT`], the same constant the
|
|
/// REQ path clamps filter limits to, so the advertised ceiling and the
|
|
/// enforced one cannot drift (see
|
|
/// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`).
|
|
///
|
|
/// `auth_required` is always `true`: the REQ, EVENT, and COUNT handlers
|
|
/// unconditionally reject connections that are not in
|
|
/// `AuthState::Authenticated`. This is independent of the REST API token
|
|
/// toggle (`config.require_auth_token`).
|
|
fn relay_limitation(max_message_length: usize, advertise_fi: bool) -> RelayLimitation {
|
|
let max_not_before_delta: u64 = std::env::var("SPROUT_MAX_NOT_BEFORE_DELTA")
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(31_536_000); // 1 year default
|
|
|
|
RelayLimitation {
|
|
max_message_length: Some(max_message_length as u64),
|
|
max_subscriptions: Some(1024),
|
|
max_filters: Some(10),
|
|
max_limit: Some(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32),
|
|
max_subid_length: Some(256),
|
|
min_pow_difficulty: None,
|
|
auth_required: true,
|
|
payment_required: false,
|
|
restricted_writes: true,
|
|
federated_identity: advertise_fi,
|
|
due_delivery_mode: Some("push".to_string()),
|
|
max_not_before_delta: Some(max_not_before_delta),
|
|
}
|
|
}
|
|
|
|
/// Build-time capability flags for [`RelayInfo::build`].
|
|
///
|
|
/// Grouping the boolean capability flags gives `build` a named seam for
|
|
/// protocol advertisement decisions and drops the argument count below the
|
|
/// clippy threshold.
|
|
#[derive(Default, Clone, Copy)]
|
|
pub(crate) struct RelayCapabilityFlags {
|
|
/// Whether NIP-43 (relay membership) is advertised in `supported_nips`.
|
|
pub advertise_nip43: bool,
|
|
/// Whether NIP-FI (federated identity) is advertised.
|
|
pub advertise_fi: bool,
|
|
}
|
|
|
|
impl RelayInfo {
|
|
/// Builds the relay's NIP-11 information document.
|
|
///
|
|
/// `relay_self` is the relay's own signing pubkey (hex), advertised as the
|
|
/// NIP-11 `self` field. NIP-11 defines `self` generically as the relay's
|
|
/// identity key; other NIPs reference it. Notably NIP-29 (group metadata
|
|
/// kinds 39000/39001/39002, which Buzz signs with `state.relay_keypair`
|
|
/// unconditionally) requires clients to verify those events against
|
|
/// `self`. Pass `Some` whenever the relay has a stable signing key.
|
|
///
|
|
/// `icon` is the community's workspace icon (see
|
|
/// [`workspace_icon_for_host`]) — a host-scoped scalar, pre-fetched by
|
|
/// the caller so `build` itself stays static-input.
|
|
///
|
|
/// `advertise_nip43` controls whether NIP-43 (relay membership) is added
|
|
/// to `supported_nips`. Set `true` only when the relay actually emits and
|
|
/// gates on NIP-43 events — i.e. has a stable key AND enforces
|
|
/// membership. NIP-43 events are verified against `self`, so it is a
|
|
/// programmer error to advertise NIP-43 without a `relay_self`.
|
|
///
|
|
/// `admin_api` is the canonical admin API origin, advertised only when the
|
|
/// admin surface is configured; a per-deployment scalar derived from
|
|
/// config by the caller (see [`nip11_document`]).
|
|
///
|
|
/// `gif_provider` is a config-derived provider identifier. When present,
|
|
/// `build` advertises the provider-agnostic `buzz-gif` extension and the
|
|
/// relay-relative metadata search endpoint. It must never contain a
|
|
/// provider credential.
|
|
pub(crate) fn build(
|
|
relay_self: Option<&str>,
|
|
icon: Option<&str>,
|
|
flags: RelayCapabilityFlags,
|
|
max_message_length: usize,
|
|
pairing_relay_url: Option<&str>,
|
|
admin_api: Option<&str>,
|
|
gif_provider: Option<&str>,
|
|
) -> Self {
|
|
let RelayCapabilityFlags {
|
|
advertise_nip43,
|
|
advertise_fi,
|
|
} = flags;
|
|
debug_assert!(
|
|
!advertise_nip43 || relay_self.is_some(),
|
|
"advertise_nip43=true requires relay_self=Some — NIP-43 events are verified against `self`"
|
|
);
|
|
|
|
let mut supported_nips = SUPPORTED_NIPS.to_vec();
|
|
if advertise_nip43 {
|
|
supported_nips.push(NIP_RELAY_MEMBERSHIP);
|
|
}
|
|
|
|
let mut supported_extensions = vec![
|
|
"nip-er".to_string(),
|
|
"nip-ar".to_string(),
|
|
// NIP-CL: identity tags, labels and system channels.
|
|
"nip-cl".to_string(),
|
|
// Bridge `/query` search accepts `search_sort: "newest"`.
|
|
"buzz-search-sort".to_string(),
|
|
// kind:9002 `unlisted` (NOSTR.md, Unlisted channels). A relay
|
|
// without it ignores the tag beside `visibility`, so a client
|
|
// checks this before opening a channel as unlisted.
|
|
"buzz-unlisted-channels".to_string(),
|
|
];
|
|
let gif = gif_provider.map(|provider| {
|
|
supported_extensions.push("buzz-gif".to_string());
|
|
GifDescriptor {
|
|
provider: provider.to_string(),
|
|
search: crate::api::gifs::SEARCH_PATH.to_string(),
|
|
share: crate::api::gifs::SHARE_PATH.to_string(),
|
|
}
|
|
});
|
|
|
|
// NIP-FI discovery descriptor. Per [FI-TRACE-DISCOVERY-PRIVATE], the
|
|
// document is byte-identical across all enrollment modes — no issuer
|
|
// URLs, audiences, claim names, or per-tenant details. Only the
|
|
// capability fact (core transport profile + freshness class) is public.
|
|
let federated_identity = advertise_fi.then(|| {
|
|
serde_json::json!({
|
|
"core": "client-attached",
|
|
"assertion_freshness": {
|
|
"class": "offline-jwt",
|
|
"maximum_residual_upstream_revocation_seconds": null
|
|
}
|
|
})
|
|
});
|
|
|
|
Self {
|
|
name: "Buzz Relay".to_string(),
|
|
description: "Buzz — private team communication relay".to_string(),
|
|
icon: icon.filter(|s| !s.is_empty()).map(|s| s.to_string()),
|
|
read_state_snapshot: None,
|
|
buzz_v1: None,
|
|
artifacts: serde_json::json!({
|
|
"version": 1, "revision_kind": 45010, "removal_kind": 45011,
|
|
"query": "/query", "count": "/count",
|
|
"modes": ["current", "history"],
|
|
"websocket_query_extensions": false,
|
|
"max_tags": buzz_core::artifact::MAX_TAGS,
|
|
"max_tag_name_bytes": buzz_core::artifact::MAX_TAG_NAME_BYTES,
|
|
"max_tag_value_bytes": buzz_core::artifact::MAX_TAG_VALUE_BYTES,
|
|
"max_tag_bytes": buzz_core::artifact::MAX_TAG_BYTES,
|
|
"max_predicates": buzz_core::artifact::MAX_PREDICATES,
|
|
"max_values": buzz_core::artifact::MAX_QUERY_VALUES,
|
|
"max_page_size": buzz_core::artifact::MAX_PAGE_SIZE,
|
|
"max_offset": buzz_core::artifact::MAX_OFFSET, "max_filters": 1, "query_timeout_ms": 2000
|
|
}),
|
|
pubkey: None,
|
|
contact: None,
|
|
supported_nips,
|
|
supported_extensions: Some(supported_extensions),
|
|
push: None,
|
|
software: "https://github.com/block/buzz".to_string(),
|
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
|
limitation: Some(relay_limitation(max_message_length, advertise_fi)),
|
|
pairing_relay_url: pairing_relay_url.map(str::to_string),
|
|
admin_api: admin_api.map(str::to_string),
|
|
gif,
|
|
relay_self: relay_self.map(|s| s.to_string()),
|
|
federated_identity,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Axum handler that returns the NIP-11 relay information document as JSON.
|
|
pub async fn relay_info_handler(
|
|
axum::extract::State(state): axum::extract::State<std::sync::Arc<crate::state::AppState>>,
|
|
headers: axum::http::HeaderMap,
|
|
) -> axum::response::Json<RelayInfo> {
|
|
let raw_host = headers
|
|
.get(axum::http::header::HOST)
|
|
.and_then(|v| v.to_str().ok())
|
|
.unwrap_or("");
|
|
axum::response::Json(nip11_document(&state, raw_host).await)
|
|
}
|
|
|
|
fn push_descriptor(
|
|
push_configured: bool,
|
|
relay_url: &str,
|
|
executor_key_id: &str,
|
|
relay_keypair: &nostr::Keys,
|
|
tenant_host: Option<&str>,
|
|
) -> Option<serde_json::Value> {
|
|
let host = tenant_host?;
|
|
push_configured.then_some(())?;
|
|
let scheme = if relay_url.starts_with("wss://") {
|
|
"wss"
|
|
} else {
|
|
"ws"
|
|
};
|
|
Some(serde_json::json!({
|
|
"origin": format!("{scheme}://{host}"),
|
|
"keys": [{
|
|
"id": executor_key_id,
|
|
"pubkey": relay_keypair.public_key().to_hex(),
|
|
"current": true
|
|
}],
|
|
"push_kinds": crate::handlers::push_lease::PUSH_KINDS,
|
|
"h_grammar": "uuid-v4-lowercase",
|
|
"class_support": {"apns": ["default"]},
|
|
"limitation": {
|
|
"max_lease_ttl": 2592000,
|
|
"max_leases_per_pubkey": 16,
|
|
"max_subscriptions_per_lease": 16,
|
|
"max_kinds": 16,
|
|
"max_authors": 20,
|
|
"max_h": 50,
|
|
"max_tag_values": 20,
|
|
"max_ignore": 8,
|
|
"max_content_len": 65536,
|
|
"max_plaintext_len": 32768,
|
|
"max_endpoint_len": 4096,
|
|
"max_string_len": 512
|
|
}
|
|
}))
|
|
}
|
|
|
|
/// Builds the served NIP-11 document for a request arriving on `raw_host`.
|
|
///
|
|
/// Centralised so the content-negotiated root handler and the dedicated
|
|
/// `/info` endpoint can't drift apart. Every input to `RelayInfo::build`
|
|
/// stays a pre-derived scalar: [`nip11_facts`] (config + keypair) plus the
|
|
/// host-scoped workspace icon. Optional provider capabilities are passed as
|
|
/// config-derived scalar identifiers; no provider credential enters NIP-11.
|
|
pub(crate) async fn nip11_document(state: &crate::state::AppState, raw_host: &str) -> RelayInfo {
|
|
let (relay_self, advertise_nip43) = nip11_facts(state);
|
|
let icon = workspace_icon_for_host(state, raw_host).await;
|
|
let admin_api = admin_api_advertisement(state.config.admin.as_ref());
|
|
let advertise_fi = state.config.nip_fi.mode.evaluates();
|
|
let mut info = RelayInfo::build(
|
|
relay_self.as_deref(),
|
|
icon.as_deref(),
|
|
RelayCapabilityFlags {
|
|
advertise_nip43,
|
|
advertise_fi,
|
|
},
|
|
state.config.max_frame_bytes,
|
|
state.config.pairing_relay_url.as_deref(),
|
|
admin_api.as_deref(),
|
|
state.config.klipy.as_ref().map(|_| "klipy"),
|
|
);
|
|
if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await {
|
|
if state.config.buzz_v1_enabled {
|
|
use buzz_db::personal_read::{ELIGIBLE_KINDS, MAX_CHANNELS, MAX_INTENTS};
|
|
info.buzz_v1 = Some(BuzzV1Descriptor {
|
|
version: 1,
|
|
base_path: crate::api::buzz_v1::BASE_PATH.to_owned(),
|
|
retention_seconds: state.config.buzz_v1_retention_seconds,
|
|
max_channels: MAX_CHANNELS,
|
|
max_intents: MAX_INTENTS,
|
|
eligible_kinds: ELIGIBLE_KINDS,
|
|
});
|
|
}
|
|
info.read_state_snapshot = Some(serde_json::json!({
|
|
"version": 1,
|
|
"community_id": tenant.community().as_uuid(),
|
|
"max_events": buzz_db::read_state::MAX_SNAPSHOT_EVENTS,
|
|
"max_event_array_bytes": buzz_db::read_state::MAX_SNAPSHOT_BYTES,
|
|
}));
|
|
}
|
|
let tenant_host = if state.config.push_enabled {
|
|
crate::tenant::bind_community(&state.db, raw_host)
|
|
.await
|
|
.ok()
|
|
.map(|tenant| tenant.host().to_owned())
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(push) = push_descriptor(
|
|
state.config.push_enabled,
|
|
&state.config.relay_url,
|
|
&state.config.push_executor_key_id,
|
|
&state.relay_keypair,
|
|
tenant_host.as_deref(),
|
|
) {
|
|
info.supported_extensions
|
|
.get_or_insert_default()
|
|
.push("buzz-push-v1".to_string());
|
|
info.push = Some(push);
|
|
}
|
|
info
|
|
}
|
|
|
|
/// Fetches the workspace icon for the community bound to `raw_host`, as the
|
|
/// host-scoped scalar consumed by [`RelayInfo::build`].
|
|
///
|
|
/// The icon is per-community state (`communities.icon`, set by relay
|
|
/// admins/owners via the kind:9033 command) served in the standard NIP-11
|
|
/// `icon` field. The lookup is scoped through
|
|
/// [`crate::tenant::bind_community`] — never an unscoped query. Fails open to
|
|
/// `None` (no `icon` field): NIP-11 is intentionally served to unmapped hosts
|
|
/// too, and an icon lookup failure must not break that.
|
|
async fn workspace_icon_for_host(state: &crate::state::AppState, raw_host: &str) -> Option<String> {
|
|
let tenant = crate::tenant::bind_community(&state.db, raw_host)
|
|
.await
|
|
.ok()?;
|
|
state
|
|
.db
|
|
.get_community_icon(tenant.community())
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
}
|
|
|
|
/// Derives the two NIP-11 facts that depend on runtime config:
|
|
///
|
|
/// - `relay_self`: the NIP-11 `self` pubkey, set whenever the relay has a
|
|
/// stable signing key. Consumed by NIP-29 (group metadata verification)
|
|
/// and NIP-43, among others. Ephemeral keys are excluded because they
|
|
/// change on restart, leaving previously-signed events unverifiable.
|
|
/// - `advertise_nip43`: whether to list NIP-43 in `supported_nips`. True
|
|
/// only when membership is actually enforced AND we have a stable key
|
|
/// (NIP-43 events must be verifiable against `self`).
|
|
///
|
|
/// Centralised so the content-negotiated root handler and the dedicated
|
|
/// `/info` endpoint can't drift apart.
|
|
pub(crate) fn nip11_facts(state: &crate::state::AppState) -> (Option<String>, bool) {
|
|
let has_stable_key = state.config.relay_private_key.is_some();
|
|
let relay_self = has_stable_key.then(|| state.relay_keypair.public_key().to_hex());
|
|
let advertise_nip43 = has_stable_key && state.config.require_relay_membership;
|
|
(relay_self, advertise_nip43)
|
|
}
|
|
|
|
/// Derives the NIP-11 `admin_api` advertisement: the canonical admin API
|
|
/// origin, present iff the admin surface is configured
|
|
/// (`config.admin.is_some()`), absent otherwise — never an empty string.
|
|
///
|
|
/// The origin is derived purely from the configured admin host by
|
|
/// [`crate::api::admin::admin_api_origin`] (loopback → `http`, else `https`),
|
|
/// so it is a per-deployment scalar with no unscoped DB/tenant input, keeping
|
|
/// [`RelayInfo::build`] within its static-input contract.
|
|
fn admin_api_advertisement(admin: Option<&crate::config::AdminConfig>) -> Option<String> {
|
|
admin.map(|admin| crate::api::admin::admin_api_origin(&admin.host))
|
|
}
|
|
|
|
/// Multi-tenant conformance static-input fence (surface row "NIP-11 relay info
|
|
/// and relay `self`").
|
|
///
|
|
/// The conformance obligation: `RelayInfo::build` "must not grow unscoped
|
|
/// DB/search/audit inputs", so an unauthenticated NIP-11 read can never become
|
|
/// an enumeration oracle for *other* communities. `build` takes only static
|
|
/// and scalar inputs — the per-deployment facts arrive pre-derived through
|
|
/// [`nip11_facts`] (config + relay keypair), and the one host-scoped fact
|
|
/// (the workspace `icon`) arrives as a scalar from
|
|
/// [`workspace_icon_for_host`], whose DB lookup is scoped through
|
|
/// [`crate::tenant::bind_community`] and can therefore only ever surface the
|
|
/// requesting host's own community state.
|
|
///
|
|
/// This const binds `RelayInfo::build` to its **exact** allowed signature. The
|
|
/// moment someone adds a `&Db`, `&AppState`, a search handle, an audit handle,
|
|
/// or any other unscoped input, the function pointer's type stops matching and
|
|
/// **this file fails to compile** — turning a silent cross-tenant leak into a
|
|
/// hard build break, the same way a deny-lint would. If you must change this
|
|
/// signature, you are changing the conformance contract: update the conformance
|
|
/// doc and prove the new input is host-scoped, not unscoped, first.
|
|
#[allow(clippy::type_complexity)]
|
|
const _RELAY_INFO_BUILD_STATIC_INPUT_FENCE: fn(
|
|
Option<&str>,
|
|
Option<&str>,
|
|
RelayCapabilityFlags,
|
|
usize,
|
|
Option<&str>,
|
|
Option<&str>,
|
|
Option<&str>,
|
|
) -> RelayInfo = RelayInfo::build;
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
// Clients attach evidence only when the relay advertises NIP-FI, so a
|
|
// shadow relay must serve enforce's exact document. Mutation: gating on
|
|
// `enforces()` makes shadow serve Off's document → RED.
|
|
#[tokio::test]
|
|
async fn shadow_serves_the_same_nip11_document_as_enforce() {
|
|
use buzz_auth::NipFiMode;
|
|
let base = crate::state::tests::test_state_with_unreachable_database_url(
|
|
"postgres://127.0.0.1:1/none",
|
|
)
|
|
.await;
|
|
let mut docs = Vec::new();
|
|
for mode in [NipFiMode::Off, NipFiMode::Enforce, NipFiMode::Shadow] {
|
|
let mut state = (*base).clone();
|
|
let mut config = (*state.config).clone();
|
|
config.nip_fi.mode = mode;
|
|
state.config = std::sync::Arc::new(config);
|
|
docs.push(
|
|
serde_json::to_string(&super::nip11_document(&state, "relay.example").await)
|
|
.unwrap(),
|
|
);
|
|
}
|
|
assert_ne!(docs[0], docs[1], "enforce advertises NIP-FI");
|
|
assert_eq!(docs[2], docs[1]);
|
|
}
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn push_descriptor_is_gated_by_gateway_configuration_and_tenant_binding() {
|
|
let keys = nostr::Keys::generate();
|
|
assert!(
|
|
push_descriptor(false, "ws://relay", "key", &keys, Some("tenant.example")).is_none()
|
|
);
|
|
assert!(push_descriptor(true, "ws://relay", "key", &keys, None).is_none());
|
|
let descriptor = push_descriptor(true, "ws://relay", "key", &keys, Some("tenant.example"))
|
|
.expect("configured push descriptor");
|
|
assert_eq!(descriptor["origin"], "ws://tenant.example");
|
|
assert_eq!(
|
|
descriptor["push_kinds"],
|
|
serde_json::json!(crate::handlers::push_lease::PUSH_KINDS)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn supported_nips_includes_nip23_and_nip33() {
|
|
// Tests the production SUPPORTED_NIPS constant directly — no Config::from_env()
|
|
// needed, avoiding the env-var race with config.rs tests.
|
|
assert!(
|
|
SUPPORTED_NIPS.contains(&23),
|
|
"NIP-23 (long-form content) must be advertised"
|
|
);
|
|
assert!(
|
|
SUPPORTED_NIPS.contains(&33),
|
|
"NIP-33 (parameterized replaceable) must be advertised"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn supported_nips_includes_nip38() {
|
|
assert!(
|
|
SUPPORTED_NIPS.contains(&38),
|
|
"NIP-38 (user statuses) must be advertised"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn supported_nips_includes_nip56() {
|
|
assert!(
|
|
SUPPORTED_NIPS.contains(&56),
|
|
"NIP-56 (reporting) must be advertised — kind:1984 ingest is live"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn build_advertises_buzz_repository_url() {
|
|
let info = RelayInfo::build(
|
|
None,
|
|
None,
|
|
RelayCapabilityFlags::default(),
|
|
DEFAULT_MAX_FRAME_BYTES,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
assert_eq!(info.software, "https://github.com/block/buzz");
|
|
}
|
|
|
|
#[test]
|
|
fn configured_pairing_relay_is_advertised_and_unset_value_is_omitted() {
|
|
let info = RelayInfo::build(
|
|
None,
|
|
None,
|
|
RelayCapabilityFlags::default(),
|
|
DEFAULT_MAX_FRAME_BYTES,
|
|
Some("wss://pairing.buzz.xyz"),
|
|
None,
|
|
None,
|
|
);
|
|
let json = serde_json::to_value(&info).expect("serialize");
|
|
assert_eq!(
|
|
json.get("pairing_relay_url")
|
|
.and_then(|value| value.as_str()),
|
|
Some("wss://pairing.buzz.xyz")
|
|
);
|
|
|
|
let info = RelayInfo::build(
|
|
None,
|
|
None,
|
|
RelayCapabilityFlags::default(),
|
|
DEFAULT_MAX_FRAME_BYTES,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
let json = serde_json::to_value(&info).expect("serialize");
|
|
assert!(json.get("pairing_relay_url").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn gif_descriptor_and_extension_are_config_gated_and_credential_free() {
|
|
let info = RelayInfo::build(
|
|
None,
|
|
None,
|
|
RelayCapabilityFlags::default(),
|
|
DEFAULT_MAX_FRAME_BYTES,
|
|
None,
|
|
None,
|
|
Some("klipy"),
|
|
);
|
|
|
|
let json = serde_json::to_value(&info).expect("serialize");
|
|
assert_eq!(json["gif"]["provider"], "klipy");
|
|
assert_eq!(json["gif"]["search"], "/gifs/search");
|
|
assert_eq!(json["gif"]["share"], "/gifs/share");
|
|
assert!(json["supported_extensions"]
|
|
.as_array()
|
|
.expect("extensions")
|
|
.contains(&serde_json::json!("buzz-gif")));
|
|
assert!(!json.to_string().contains("api_key"));
|
|
|
|
let unconfigured = RelayInfo::build(
|
|
None,
|
|
None,
|
|
RelayCapabilityFlags::default(),
|
|
DEFAULT_MAX_FRAME_BYTES,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
assert!(unconfigured.gif.is_none());
|
|
assert!(!unconfigured
|
|
.supported_extensions
|
|
.expect("extensions")
|
|
.contains(&"buzz-gif".to_string()));
|
|
}
|
|
|
|
/// Clients refuse NIP-CL labels and system channels, newest-first
|
|
/// search, and unlisted channels unless the relay says it supports them,
|
|
/// so every document must advertise all three.
|
|
#[test]
|
|
fn advertises_nip_cl_search_sort_and_unlisted_extensions() {
|
|
let info = RelayInfo::build(
|
|
None,
|
|
None,
|
|
RelayCapabilityFlags::default(),
|
|
DEFAULT_MAX_FRAME_BYTES,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
let extensions = info.supported_extensions.expect("extensions");
|
|
assert!(extensions.contains(&"nip-cl".to_string()));
|
|
assert!(extensions.contains(&"buzz-search-sort".to_string()));
|
|
assert!(extensions.contains(&"buzz-unlisted-channels".to_string()));
|
|
}
|
|
|
|
/// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard
|
|
/// `icon` field; no icon (or a cleared, empty icon) omits the field
|
|
/// entirely so the JSON matches pre-icon documents byte-for-byte.
|
|
#[test]
|
|
fn icon_is_mirrored_and_empty_or_absent_is_omitted() {
|
|
let info = RelayInfo::build(
|
|
None,
|
|
Some("data:image/webp;base64,UklGRg=="),
|
|
RelayCapabilityFlags::default(),
|
|
DEFAULT_MAX_FRAME_BYTES,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
assert_eq!(
|
|
info.icon.as_deref(),
|
|
Some("data:image/webp;base64,UklGRg==")
|
|
);
|
|
let json = serde_json::to_value(&info).expect("serialize");
|
|
assert_eq!(
|
|
json.get("icon").and_then(|v| v.as_str()),
|
|
Some("data:image/webp;base64,UklGRg==")
|
|
);
|
|
|
|
for icon in [None, Some("")] {
|
|
let info = RelayInfo::build(
|
|
None,
|
|
icon,
|
|
RelayCapabilityFlags::default(),
|
|
DEFAULT_MAX_FRAME_BYTES,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
assert!(info.icon.is_none());
|
|
let json = serde_json::to_value(&info).expect("serialize");
|
|
assert!(
|
|
json.get("icon").is_none(),
|
|
"unset/cleared icon must omit the `icon` field, not serialize null/empty"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn auth_required_is_advertised_true() {
|
|
// REQ, EVENT, and COUNT all unconditionally require
|
|
// `AuthState::Authenticated` (see `crates/buzz-relay/src/handlers/`),
|
|
// so the NIP-11 doc must advertise it.
|
|
assert!(relay_limitation(DEFAULT_MAX_FRAME_BYTES, false).auth_required);
|
|
}
|
|
|
|
#[test]
|
|
fn max_message_length_uses_configured_frame_limit() {
|
|
let info = RelayInfo::build(
|
|
None,
|
|
None,
|
|
RelayCapabilityFlags::default(),
|
|
262_144,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
let limitation = info.limitation.expect("limitation");
|
|
assert_eq!(limitation.max_message_length, Some(262_144));
|
|
}
|
|
|
|
#[test]
|
|
fn supported_nips_are_sorted() {
|
|
let mut sorted = SUPPORTED_NIPS.to_vec();
|
|
sorted.sort();
|
|
assert_eq!(
|
|
SUPPORTED_NIPS,
|
|
&sorted[..],
|
|
"supported_nips should be sorted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn nip43_not_in_static_supported_nips() {
|
|
// NIP-43 advertisement is conditional on runtime config (stable signing
|
|
// key + membership enforcement) and must NOT live in the static list.
|
|
// The desktop pairing probe keys off this NIP — advertising it on
|
|
// open relays misroutes pairing peers to a non-existent /pair sidecar.
|
|
assert!(
|
|
!SUPPORTED_NIPS.contains(&NIP_RELAY_MEMBERSHIP),
|
|
"NIP-43 must be advertised only when advertise_nip43=true is passed to RelayInfo::build"
|
|
);
|
|
}
|
|
|
|
/// Open relay, ephemeral key — both `self` and NIP-43 are absent.
|
|
#[test]
|
|
fn build_open_relay_ephemeral_key_omits_self_and_nip43() {
|
|
let info = RelayInfo::build(
|
|
None,
|
|
None,
|
|
RelayCapabilityFlags::default(),
|
|
DEFAULT_MAX_FRAME_BYTES,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
assert!(info.relay_self.is_none());
|
|
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
|
|
}
|
|
|
|
/// Open relay with a stable signing key (e.g. for NIP-29 group metadata
|
|
/// signing): `self` MUST be advertised so clients can verify those
|
|
/// events; NIP-43 must NOT be, because the relay isn't enforcing
|
|
/// membership. This is the staging-default shape — the bug we're
|
|
/// fixing — and the regression we must not reintroduce.
|
|
#[test]
|
|
fn build_open_relay_stable_key_advertises_self_but_not_nip43() {
|
|
let pk = "0000000000000000000000000000000000000000000000000000000000000001";
|
|
let info = RelayInfo::build(
|
|
Some(pk),
|
|
None,
|
|
RelayCapabilityFlags::default(),
|
|
DEFAULT_MAX_FRAME_BYTES,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
assert_eq!(info.relay_self.as_deref(), Some(pk));
|
|
assert!(!info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
|
|
}
|
|
|
|
/// Membership-enforcing relay: both `self` and NIP-43 advertised.
|
|
#[test]
|
|
fn build_membership_relay_advertises_self_and_nip43() {
|
|
let pk = "0000000000000000000000000000000000000000000000000000000000000001";
|
|
let info = RelayInfo::build(
|
|
Some(pk),
|
|
None,
|
|
RelayCapabilityFlags {
|
|
advertise_nip43: true,
|
|
advertise_fi: false,
|
|
},
|
|
DEFAULT_MAX_FRAME_BYTES,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
assert_eq!(info.relay_self.as_deref(), Some(pk));
|
|
assert!(info.supported_nips.contains(&NIP_RELAY_MEMBERSHIP));
|
|
}
|
|
|
|
/// NIP-43 events are verified against `self`; advertising NIP-43 without
|
|
/// `self` would give clients no way to verify membership events. The
|
|
/// debug_assert in `build` catches this in tests/debug builds.
|
|
#[test]
|
|
#[should_panic(expected = "advertise_nip43=true requires relay_self=Some")]
|
|
fn build_nip43_without_self_panics_in_debug() {
|
|
let _ = RelayInfo::build(
|
|
None,
|
|
None,
|
|
RelayCapabilityFlags {
|
|
advertise_nip43: true,
|
|
advertise_fi: false,
|
|
},
|
|
DEFAULT_MAX_FRAME_BYTES,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
}
|
|
|
|
fn admin_config(host: &str) -> crate::config::AdminConfig {
|
|
crate::config::AdminConfig {
|
|
host: host.to_string(),
|
|
auth: crate::config::AdminAuth::Nip98,
|
|
web_dir: None,
|
|
}
|
|
}
|
|
|
|
/// The admin surface is unconfigured: `admin_api` must be absent, and the
|
|
/// serialized document must omit the field entirely (not `null`).
|
|
#[test]
|
|
fn admin_api_absent_when_admin_surface_not_configured() {
|
|
assert_eq!(admin_api_advertisement(None), None);
|
|
|
|
let info = RelayInfo::build(
|
|
None,
|
|
None,
|
|
RelayCapabilityFlags::default(),
|
|
DEFAULT_MAX_FRAME_BYTES,
|
|
None,
|
|
None,
|
|
None,
|
|
);
|
|
assert!(info.admin_api.is_none());
|
|
let json = serde_json::to_value(&info).expect("serialize");
|
|
assert!(
|
|
json.get("admin_api").is_none(),
|
|
"unconfigured admin surface must omit the `admin_api` field"
|
|
);
|
|
}
|
|
|
|
/// Loopback admin host → advertised as an `http://` origin (matches the
|
|
/// NIP-98 canonicalizer's loopback rule so a discovered origin signs
|
|
/// against the scheme the relay verifies).
|
|
#[test]
|
|
fn admin_api_advertised_as_http_for_loopback_host() {
|
|
let advertised = admin_api_advertisement(Some(&admin_config("127.0.0.1:3000")));
|
|
assert_eq!(advertised.as_deref(), Some("http://127.0.0.1:3000"));
|
|
|
|
let info = RelayInfo::build(
|
|
None,
|
|
None,
|
|
RelayCapabilityFlags::default(),
|
|
DEFAULT_MAX_FRAME_BYTES,
|
|
None,
|
|
advertised.as_deref(),
|
|
None,
|
|
);
|
|
let json = serde_json::to_value(&info).expect("serialize");
|
|
assert_eq!(
|
|
json.get("admin_api").and_then(|v| v.as_str()),
|
|
Some("http://127.0.0.1:3000")
|
|
);
|
|
}
|
|
|
|
/// Non-loopback admin host → advertised as an `https://` origin, with no
|
|
/// path/query/fragment (a bare origin).
|
|
#[test]
|
|
fn admin_api_advertised_as_https_for_non_loopback_host() {
|
|
let advertised = admin_api_advertisement(Some(&admin_config("admin.example.com")));
|
|
assert_eq!(advertised.as_deref(), Some("https://admin.example.com"));
|
|
}
|
|
}
|