matrix: name the credential after the account it authenticates as

The store path and every identifier around it called this an admin
token. It is not one: of ~15 hive-c0re call sites only two need
homeserver admin, and the homeserver no longer promotes the account at
boot, so the name overstated both what the credential is and what it may
do.

Renaming it to the account was not enough either. "The `@hive:` token"
reads as the token of a hive user, and no such user is provisioned —
`@hive:<server_name>` is the appservice registration's own
`sender_localpart`, an account the homeserver creates for itself when it
loads the registration.

So it is the **sender token**: the matrix appservice sender account's
access token, at `swarm/services/matrix/sender-token`. The name says
what it authenticates as rather than what it may do, which is the part
that was wrong.

The path has one constructor, and the bao grant, the grant assertion and
three unit tests pin its literal independently — so a half-finished
rename fails a check rather than leaving the minter and its readers
disagreeing at runtime. `tracing` messages are renamed with the code, so
the journal reads the way the source does.

The host-side file keeps its name (`matrix/access-token`): it carried no
admin framing, and renaming it would orphan the file on every deployed
hive for nothing.

`docs/tools/hivectl-cli.md` is regenerated from the clap tree.
This commit is contained in:
atlas 2026-09-20 13:28:01 +02:00 committed by mara
commit fb9c6122df
18 changed files with 177 additions and 150 deletions

View file

@ -229,7 +229,7 @@ control: [`swarm/ui.md`](../swarm/ui.md).
### 6 · Matrix
```bash
# Ensure the hive's own `@hive:` account exists first
# Ensure the appservice's sender account exists first
hivectl matrix sync-admin
# Provision ruth's own matrix account — same bootstrap-bypass reasoning
@ -293,7 +293,7 @@ See [`tools/hivectl.md`](../tools/hivectl.md) for every `hivectl` verb.
- **No forge admin token is stored in any agent state dir.** Agents
hold a regular agent token in their `forge-token` file; sensitive
creds (the core token, the `@hive:` matrix access token) live on the host.
creds (the core token, the matrix sender token) live on the host.
- All config changes (forge PRs on `agent-configs/<name>`) go through
operator approval — agents can't unilaterally rebuild containers, by design.
See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md).

View file

@ -147,7 +147,7 @@ a token.
(`hive-matrix-daemon.path` watching for `matrix-token` appearance)
brings the daemon up on the same boot cycle anyway.
### The `@hive:` account, and why it isn't an admin
### The appservice's sender account, and why it isn't an admin
`@hive:<server_name>` is the appservice's own `sender_localpart`, which
the homeserver creates itself when it loads the registration — on a
@ -155,6 +155,14 @@ zero-user database, inside startup, before the HTTP listener accepts
anything. It's an **ordinary account**: nothing promotes it, and the
homeserver runs no `admin_execute` for it.
Its access token is the **sender token**, and it's the credential
hive-c0re presents for every homeserver call it makes on the hive's
behalf. `swarm-matrix-minter` mints it inside the `hive-matrix`
container and publishes it to `swarm/services/matrix/sender-token`; the
hive reads it from there. The name says what it authenticates as — an
account the appservice registration brings into being — rather than any
privilege level, because it carries none.
It needs no promotion for what the hive does with it. Creating the hive
Space and the chat room, writing their hierarchy and join rules, and
inviting agents into them are all ordinary client calls that ride on
@ -187,7 +195,7 @@ restarts, so the first boot after the switch already has both halves.
- **The per-agent sweep honours existing token files.** It skips any
agent that already has a `matrix-token`, so it re-registers no account
and displaces no session.
- **`@hive:` may already be an admin** on such a hive (it won the
- **The sender account may already be an admin** on such a hive (it won the
first-user grant when the hive was new). Nothing here demotes it; the
homeserver no longer promotes it, so a hive built fresh has an
ordinary account and an older one keeps whatever standing it acquired.

View file

@ -55,13 +55,13 @@ strategy for every credential, including the mTLS leaf.
<!-- vale write-good.Passive = NO -->
| store path | minter | reader — pulls at runtime, holds in memory | renewal |
| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| -------------------------------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
| `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated |
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
| `swarm/services/<clientId>/oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated |
| `swarm/services/matrix/hive-access-token` | `swarm-matrix-minter`, in the `hive-matrix` container | `swarm-matrix-minter` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_hive_token()`, under the hive's own certificate | must be stated |
| `swarm/services/matrix/sender-token` | `swarm-matrix-minter`, in the `hive-matrix` container | `swarm-matrix-minter` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated |
| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated |
<!-- vale write-good.Passive = YES -->

View file

@ -165,7 +165,7 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for
###### **Subcommands:**
* `create-user` — Create or refresh the matrix account + access token for `<name>`
* `sync-admin` — Provision (or re-provision) the hive system admin matrix account
* `sync-admin` — Provision (or re-provision) the matrix appservice's sender account
* `promote-user` — Promote a matrix user to homeserver admin
* `reset-password` — Reset a matrix user's password via the admin API
* `invite` — Invite a matrix user to the hive Space, or a specific room with `--room`. Idempotent
@ -193,7 +193,7 @@ For an existing agent, persists the token to its state dir; for a human/other ac
## `hivectl matrix sync-admin`
Provision (or re-provision) the hive's own `@hive:` matrix account.
Provision (or re-provision) the matrix appservice's sender account.
Runs automatically on startup; run manually to recover a missing access token.

View file

@ -64,7 +64,7 @@ running (`services.hyperhive.deploy.matrix.enable = true`).
hivectl matrix create-user iris # provision (or re-provision) matrix account for agent `iris`
hivectl matrix create-user mara # create matrix account for a human; prints access_token to stdout
hivectl matrix create-user mara --password hunter2 # set a client-login password
hivectl matrix sync-admin # provision / refresh the hive's own `@hive:` account
hivectl matrix sync-admin # provision / refresh the appservice's sender account
hivectl matrix promote-user mara # promote an existing matrix user to homeserver admin
hivectl matrix reset-password iris # generate and set a new random password for `iris`; prints it
hivectl matrix invite mara # invite a user to the hive Space
@ -74,7 +74,7 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec
- `create-user`: for agents, persists the `access_token` to
`<state>/matrix-token`. Skips registration when the file already
exists — delete it first to force re-registration.
- `sync-admin`: ensures the hive's own `@hive:` matrix user exists
- `sync-admin`: ensures the appservice's sender account (`@hive:<server_name>`) exists
(the account `hive-c0re` provisions rooms with). Token persisted to the
access token path. Safe to run again — idempotent.
- `promote-user`: promotes an already-registered user to homeserver
@ -87,7 +87,7 @@ hivectl matrix invite @mara:server --room '#hive-chat:server' # ...or to a spec
credentials.
- `invite`: invites a matrix user (full `@user:server` or a bare
localpart, qualified with the homeserver's `server_name`) to the hive
Space by default, or to a `--room` id / `#alias`. Uses the `@hive:`
Space by default, or to a `--room` id / `#alias`. Uses the sender
token; the account must be a member of the target room with
invite power (it owns the hive Space, so that case always works).
Idempotent — already-member / already-invited is a no-op.

View file

@ -97,11 +97,11 @@ pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat";
const HIVE_CHAT_ROOM_TOPIC: &str =
"Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re.";
/// Host path for the `@hive:` account's matrix access token. Outside every
/// Host path for the appservice sender account's matrix access token. Outside every
/// purgeable path — not deleted by `destroy --purge` on any agent.
#[must_use]
pub fn hive_token_path() -> PathBuf {
crate::paths::matrix_hive_token()
pub fn sender_token_path() -> PathBuf {
crate::paths::matrix_sender_token()
}
/// Token file inside the agent's bind-mounted state dir (visible as
@ -399,12 +399,12 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
/// but the password file is absent — covers the case where agent state dirs
/// were wiped but the homeserver still has the accounts.
async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result<String> {
let hive_token = read_hive_token()
.context("matrix: the @hive: access token is unavailable for auto-recovery")?;
let sender_token = read_sender_token()
.context("matrix: the matrix sender token is unavailable for auto-recovery")?;
let server_name = discover_server_name(client)
.await
.context("matrix: discover_server_name for auto-recovery")?;
let effective_password = reset_user_password(client, &hive_token, name, &server_name)
let effective_password = reset_user_password(client, &sender_token, name, &server_name)
.await
.with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?;
tracing::info!(%name, "matrix: auto-recovered password via admin-room reset");
@ -424,7 +424,7 @@ fn encode_room_id_for_url(room_id: &str) -> String {
/// Look up the room ID for the `#admins:<server>` alias.
async fn discover_admin_room_id(
client: &reqwest::Client,
hive_token: &str,
sender_token: &str,
server_name: &str,
) -> Result<String> {
let base = matrix_base()?;
@ -433,7 +433,7 @@ async fn discover_admin_room_id(
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}");
let resp = client
.get(&url)
.bearer_auth(hive_token)
.bearer_auth(sender_token)
.send()
.await
.context("matrix: GET admin room alias")?;
@ -561,7 +561,7 @@ mod extract_new_password_tests {
/// Generic over `T` so both password-returning and `()` callers share the loop.
async fn admin_room_send_and_poll<T>(
client: &reqwest::Client,
hive_token: &str,
sender_token: &str,
server_name: &str,
room_url: &str,
command: &str,
@ -574,7 +574,7 @@ async fn admin_room_send_and_poll<T>(
format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}");
let send_resp = client
.put(&send_url)
.bearer_auth(hive_token)
.bearer_auth(sender_token)
.json(&serde_json::json!({"msgtype": "m.text", "body": command}))
.send()
.await
@ -601,7 +601,7 @@ async fn admin_room_send_and_poll<T>(
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let poll_json = client
.get(&poll_url)
.bearer_auth(hive_token)
.bearer_auth(sender_token)
.send()
.await
.context("matrix: admin room poll")?
@ -655,16 +655,16 @@ async fn admin_room_send_and_poll<T>(
/// Returns the new password; caller is responsible for persisting it.
async fn admin_room_reset_password(
client: &reqwest::Client,
hive_token: &str,
sender_token: &str,
server_name: &str,
localpart: &str,
) -> Result<String> {
let room_id = discover_admin_room_id(client, hive_token, server_name).await?;
let room_id = discover_admin_room_id(client, sender_token, server_name).await?;
let room_url = encode_room_id_for_url(&room_id);
let command = format!("!admin users reset-password @{localpart}:{server_name}");
admin_room_send_and_poll(
client,
hive_token,
sender_token,
server_name,
&room_url,
&command,
@ -894,7 +894,7 @@ pub async fn sync_agent_standalone(name: &str) {
}
/// Ensure the `@hive:` matrix user exists and that its access token is
/// persisted at [`hive_token_path()`].
/// persisted at [`sender_token_path()`].
///
/// **Nothing here depends on registration order, and nothing here is
/// privileged.** The account used to have to be the first ever
@ -916,16 +916,16 @@ pub async fn sync_agent_standalone(name: &str) {
/// minter.
pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let path = hive_token_path();
let path = sender_token_path();
if path.exists()
&& let Ok(existing) = std::fs::read_to_string(&path)
&& !existing.trim().is_empty()
{
tracing::debug!("matrix: the @hive: access token is already present");
tracing::debug!("matrix: the sender token is already present");
return Ok(());
}
if let Some(token) = stored_hive_token().await {
return persist_hive_token(&path, &token);
if let Some(token) = stored_sender_token().await {
return persist_sender_token(&path, &token);
}
let password = random_password()?;
let access_token = match register_user(client, HIVE_LOCALPART, as_token, &password).await {
@ -973,34 +973,30 @@ pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Resul
}
Err(other) => return Err(other),
};
persist_hive_token(&path, &access_token)
persist_sender_token(&path, &access_token)
}
/// Write the `@hive:` account's access token to `path`, 0600, creating the
/// Write the appservice sender account's access token to `path`, 0600, creating the
/// directory if it is not there.
///
/// Shared by both arms of [`ensure_hive_user`] rather than duplicated into
/// the store one: the file's mode is the only thing keeping an unprivileged
/// reader off the hive's matrix credential, and a second copy of that decision
/// is one that can be edited alone.
fn persist_hive_token(path: &std::path::Path, access_token: &str) -> Result<()> {
fn persist_sender_token(path: &std::path::Path, access_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(path, format!("{access_token}\n")).with_context(|| {
format!(
"matrix: write the @hive: access token to {}",
path.display()
)
})?;
std::fs::write(path, format!("{access_token}\n"))
.with_context(|| format!("matrix: write the sender token to {}", path.display()))?;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
tracing::info!(path = %path.display(), "matrix: provisioned the @hive: access token");
tracing::info!(path = %path.display(), "matrix: provisioned the sender token");
Ok(())
}
/// Fetch the `@hive:` access token `swarm-matrix-minter` published, under
/// Fetch the sender token `swarm-matrix-minter` published, under
/// this hive's own store identity.
///
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
@ -1017,15 +1013,15 @@ fn persist_hive_token(path: &std::path::Path, access_token: &str) -> Result<()>
/// sweep.
///
/// 🩸 Logs the store **path** and never the value.
async fn stored_hive_token() -> Option<String> {
async fn stored_sender_token() -> Option<String> {
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
.ok()
.filter(|h| !h.is_empty())?;
let path = swarm_secret_client::matrix::hive_token_path();
let path = swarm_secret_client::matrix::sender_token_path();
let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
Ok(store) => store,
Err(e) => {
tracing::debug!(error = %e, "matrix: no swarm secret store to read the @hive: access token from");
tracing::debug!(error = %e, "matrix: no swarm secret store to read the sender token from");
return None;
}
};
@ -1034,15 +1030,15 @@ async fn stored_hive_token() -> Option<String> {
.await
{
Ok(credential) if !credential.value.trim().is_empty() => {
tracing::info!(%path, "matrix: taking the @hive: access token from the swarm store");
tracing::info!(%path, "matrix: taking the sender token from the swarm store");
Some(credential.value)
}
Ok(_) => {
tracing::warn!(%path, "matrix: the stored @hive: credential is empty; minting instead");
tracing::warn!(%path, "matrix: the stored sender token is empty; minting instead");
None
}
Err(e) => {
tracing::debug!(%path, error = %e, "matrix: no @hive: credential in the store; minting instead");
tracing::debug!(%path, error = %e, "matrix: no sender token in the store; minting instead");
None
}
}
@ -1116,16 +1112,16 @@ mod is_make_admin_success_tests {
/// intended long-term mechanism, not a stopgap awaiting an upstream fix.
pub async fn promote_user_to_admin(
client: &reqwest::Client,
hive_token: &str,
sender_token: &str,
localpart: &str,
server_name: &str,
) -> Result<()> {
let room_id = discover_admin_room_id(client, hive_token, server_name).await?;
let room_id = discover_admin_room_id(client, sender_token, server_name).await?;
let room_url = encode_room_id_for_url(&room_id);
let command = format!("!admin users make-user-admin @{localpart}:{server_name}");
admin_room_send_and_poll(
client,
hive_token,
sender_token,
server_name,
&room_url,
&command,
@ -1155,11 +1151,11 @@ pub async fn promote_user_to_admin(
/// Returns the new password for use in subsequent `login_user` calls.
pub async fn reset_user_password(
client: &reqwest::Client,
hive_token: &str,
sender_token: &str,
localpart: &str,
server_name: &str,
) -> Result<String> {
let pw = admin_room_reset_password(client, hive_token, server_name, localpart)
let pw = admin_room_reset_password(client, sender_token, server_name, localpart)
.await
.with_context(|| {
format!("matrix: admin-room password reset for @{localpart}:{server_name}")
@ -1209,17 +1205,17 @@ pub async fn discover_server_name(client: &reqwest::Client) -> Result<String> {
})
}
/// Read the `@hive:` access token from disk. Returns an error if it
/// Read the sender token from disk. Returns an error if it
/// is absent — callers should gate their homeserver calls on this.
pub fn read_hive_token() -> Result<String> {
let path = hive_token_path();
pub fn read_sender_token() -> Result<String> {
let path = sender_token_path();
std::fs::read_to_string(&path)
.ok()
.map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty())
.with_context(|| {
format!(
"the @hive: matrix access token was not found at {} — \
"the matrix sender token was not found at {} — \
ensure hive-c0re has started at least once with matrix enabled \
(it provisions the @hive: account on boot)",
path.display()
@ -1248,12 +1244,12 @@ fn persist_space_room_id(room_id: &str) -> Result<()> {
///
/// Name-based (not alias-based) rediscovery keeps the Space free of any
/// special-char room alias — the hardcoded plain name is the anchor.
async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Option<String> {
async fn find_space_by_name(client: &reqwest::Client, sender_token: &str) -> Option<String> {
let base = matrix_http()?;
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
let joined: serde_json::Value = client
.get(&joined_url)
.bearer_auth(hive_token)
.bearer_auth(sender_token)
.send()
.await
.ok()?
@ -1268,7 +1264,12 @@ async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Optio
let encoded = encode_room_id_for_url(room_id);
// Must be an m.space (m.room.create `type`).
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
let is_space = match client.get(&create_url).bearer_auth(hive_token).send().await {
let is_space = match client
.get(&create_url)
.bearer_auth(sender_token)
.send()
.await
{
Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>()
.await
@ -1281,7 +1282,7 @@ async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Optio
}
// …and named HIVE_SPACE_NAME (m.room.name `name`).
let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
let name_matches = match client.get(&name_url).bearer_auth(hive_token).send().await {
let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await {
Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>()
.await
@ -1312,7 +1313,7 @@ async fn find_space_by_name(client: &reqwest::Client, hive_token: &str) -> Optio
///
/// Returns an error if the homeserver is unreachable, `createRoom` fails,
/// or the room-ID file cannot be written.
pub async fn ensure_hive_space(client: &reqwest::Client, hive_token: &str) -> Result<String> {
pub async fn ensure_hive_space(client: &reqwest::Client, sender_token: &str) -> Result<String> {
let base = matrix_base()?;
// 1. Stored room id wins (fast path).
if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) {
@ -1325,7 +1326,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, hive_token: &str) -> Re
// 2. No stored id — rediscover the existing space by its hardcoded name
// before creating a new one (prevents duplicate spaces after a wipe).
if let Some(room_id) = find_space_by_name(client, hive_token).await {
if let Some(room_id) = find_space_by_name(client, sender_token).await {
persist_space_room_id(&room_id)?;
tracing::info!(%room_id, "matrix: recovered hive space by name");
return Ok(room_id);
@ -1341,7 +1342,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, hive_token: &str) -> Re
});
let resp = client
.post(&url)
.bearer_auth(hive_token)
.bearer_auth(sender_token)
.json(&body)
.send()
.await
@ -1381,12 +1382,12 @@ fn persist_chat_room_id(room_id: &str) -> Result<()> {
/// [`find_space_by_name`] so a lost room-id file recovers the existing chat
/// room instead of spawning a duplicate. `None` if the homeserver is
/// unreachable or no match exists.
async fn find_chat_room_by_name(client: &reqwest::Client, hive_token: &str) -> Option<String> {
async fn find_chat_room_by_name(client: &reqwest::Client, sender_token: &str) -> Option<String> {
let base = matrix_http()?;
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
let joined: serde_json::Value = client
.get(&joined_url)
.bearer_auth(hive_token)
.bearer_auth(sender_token)
.send()
.await
.ok()?
@ -1401,7 +1402,12 @@ async fn find_chat_room_by_name(client: &reqwest::Client, hive_token: &str) -> O
let encoded = encode_room_id_for_url(room_id);
// Skip the Space itself (and any other m.space).
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/");
let is_space = match client.get(&create_url).bearer_auth(hive_token).send().await {
let is_space = match client
.get(&create_url)
.bearer_auth(sender_token)
.send()
.await
{
Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>()
.await
@ -1414,7 +1420,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, hive_token: &str) -> O
}
// …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`).
let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
let name_matches = match client.get(&name_url).bearer_auth(hive_token).send().await {
let name_matches = match client.get(&name_url).bearer_auth(sender_token).send().await {
Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>()
.await
@ -1454,10 +1460,10 @@ fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json::
/// too, loudly — and a 404 is the expected first-setup case.
async fn current_room_state(
client: &reqwest::Client,
hive_token: &str,
sender_token: &str,
url: &str,
) -> Option<serde_json::Value> {
let resp = match client.get(url).bearer_auth(hive_token).send().await {
let resp = match client.get(url).bearer_auth(sender_token).send().await {
Ok(resp) => resp,
Err(e) => {
tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing");
@ -1485,7 +1491,7 @@ async fn current_room_state(
}
}
/// PUT a state event into `room_id` using the `@hive:` token, **skipping the
/// PUT a state event into `room_id` using the sender token, **skipping the
/// write when the room already carries identical content**.
///
/// The read is not an optimisation. A PUT of identical content is a no-op
@ -1500,7 +1506,7 @@ async fn current_room_state(
/// missing or divergent link still gets written.
async fn set_room_state(
client: &reqwest::Client,
hive_token: &str,
sender_token: &str,
room_id: &str,
event_type: &str,
state_key: &str,
@ -1511,7 +1517,7 @@ async fn set_room_state(
let encoded_key = encode_room_id_for_url(state_key);
let url =
format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}");
let current = current_room_state(client, hive_token, &url).await;
let current = current_room_state(client, sender_token, &url).await;
if !state_needs_write(current.as_ref(), content) {
tracing::debug!(
%room_id,
@ -1522,7 +1528,7 @@ async fn set_room_state(
}
let resp = client
.put(&url)
.bearer_auth(hive_token)
.bearer_auth(sender_token)
.json(content)
.send()
.await
@ -1559,7 +1565,7 @@ async fn set_room_state(
/// link is logged but not fatal (the room still exists + is joinable).
pub async fn ensure_hive_chat_room(
client: &reqwest::Client,
hive_token: &str,
sender_token: &str,
space_room_id: &str,
server_name: &str,
) -> Result<String> {
@ -1573,7 +1579,7 @@ pub async fn ensure_hive_chat_room(
{
tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned");
id
} else if let Some(id) = find_chat_room_by_name(client, hive_token).await {
} else if let Some(id) = find_chat_room_by_name(client, sender_token).await {
persist_chat_room_id(&id)?;
tracing::info!(room_id = %id, "matrix: recovered hive chat room by name");
id
@ -1613,7 +1619,7 @@ pub async fn ensure_hive_chat_room(
});
let resp = client
.post(&url)
.bearer_auth(hive_token)
.bearer_auth(sender_token)
.json(&body)
.send()
.await
@ -1644,7 +1650,7 @@ pub async fn ensure_hive_chat_room(
});
if let Err(e) = set_room_state(
client,
hive_token,
sender_token,
space_room_id,
"m.space.child",
&room_id,
@ -1662,21 +1668,21 @@ pub async fn ensure_hive_chat_room(
/// account. Idempotent — treats already-member responses as success.
async fn invite_to_room(
client: &reqwest::Client,
hive_token: &str,
sender_token: &str,
room_id: &str,
localpart: &str,
server_name: &str,
) -> Result<()> {
let user_id = format!("@{localpart}:{server_name}");
invite_user_id(client, hive_token, room_id, &user_id).await
invite_user_id(client, sender_token, room_id, &user_id).await
}
/// Fetch a user's current membership in a room via the `@hive:` token, or
/// Fetch a user's current membership in a room via the sender token, or
/// `None` if there is no membership event (never invited) or the lookup
/// fails. Returns the raw membership string (`invite`, `join`, `leave`, …).
async fn room_membership(
client: &reqwest::Client,
hive_token: &str,
sender_token: &str,
encoded_room_id: &str,
user_id: &str,
) -> Option<String> {
@ -1687,7 +1693,12 @@ async fn room_membership(
let url = format!(
"{base}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}"
);
let resp = client.get(&url).bearer_auth(hive_token).send().await.ok()?;
let resp = client
.get(&url)
.bearer_auth(sender_token)
.send()
.await
.ok()?;
if !resp.status().is_success() {
// 404 = no membership event yet; anything else we treat as "unknown"
// and let the caller fall through to the invite attempt.
@ -1698,13 +1709,13 @@ async fn room_membership(
}
/// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id`
/// using the `@hive:` token. Idempotent: a user who is already a member or
/// using the sender token. Idempotent: a user who is already a member or
/// already has a pending invite is left untouched (no fresh invite is sent,
/// so they are not re-notified), and a 403 `M_FORBIDDEN` / `M_BAD_STATE`
/// from a racing invite is still treated as success.
async fn invite_user_id(
client: &reqwest::Client,
hive_token: &str,
sender_token: &str,
room_id: &str,
user_id: &str,
) -> Result<()> {
@ -1716,7 +1727,7 @@ async fn invite_user_id(
// Skip the invite entirely when the user is already invited or joined.
// Re-POSTing an invite to a pending member re-sends the invite event,
// which re-notifies the agent on every provisioning sweep.
if let Some(membership) = room_membership(client, hive_token, &encoded_room_id, user_id).await
if let Some(membership) = room_membership(client, sender_token, &encoded_room_id, user_id).await
&& matches!(membership.as_str(), "invite" | "join")
{
tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)");
@ -1726,7 +1737,7 @@ async fn invite_user_id(
let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite");
let resp = client
.post(&url)
.bearer_auth(hive_token)
.bearer_auth(sender_token)
.json(&serde_json::json!({ "user_id": user_id }))
.send()
.await
@ -1759,13 +1770,13 @@ async fn invite_user_id(
///
/// # Errors
///
/// Returns an error if the `@hive:` token or `server_name` can't be read,
/// Returns an error if the sender token or `server_name` can't be read,
/// the target room can't be resolved (no `--room` and no persisted hive
/// space), or the invite POST fails for a reason other than the user
/// already being a member / invited.
pub async fn invite_user(
client: &reqwest::Client,
hive_token: &str,
sender_token: &str,
user: &str,
room_override: Option<&str>,
server_name: &str,
@ -1779,7 +1790,7 @@ pub async fn invite_user(
// Resolve the room: explicit override (id or #alias) wins; otherwise
// the persisted hive Space.
let room_id = match room_override {
Some(r) if r.starts_with('#') => resolve_room_alias(client, hive_token, r).await?,
Some(r) if r.starts_with('#') => resolve_room_alias(client, sender_token, r).await?,
Some(r) if r.starts_with('!') => r.to_owned(),
Some(r) => anyhow::bail!(
"matrix: --room {r:?} is neither a room id nor an alias; \
@ -1793,14 +1804,14 @@ pub async fn invite_user(
(run the hive-c0re matrix sweep first)",
)?,
};
invite_user_id(client, hive_token, &room_id, &user_id).await?;
invite_user_id(client, sender_token, &room_id, &user_id).await?;
Ok(room_id)
}
/// Resolve a `#alias:server` to its room id via the directory API.
async fn resolve_room_alias(
client: &reqwest::Client,
hive_token: &str,
sender_token: &str,
alias: &str,
) -> Result<String> {
let base = matrix_base()?;
@ -1808,7 +1819,7 @@ async fn resolve_room_alias(
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}");
let resp = client
.get(&url)
.bearer_auth(hive_token)
.bearer_auth(sender_token)
.send()
.await
.with_context(|| format!("matrix: GET directory for {alias}"))?;
@ -1899,10 +1910,10 @@ pub async fn ensure_all() -> bool {
async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool {
let mut ok = true;
// server_name is needed to form full Matrix user IDs for invites.
let hive_token = match read_hive_token() {
let sender_token = match read_sender_token() {
Ok(t) => t,
Err(e) => {
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no @hive: access token)");
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no matrix sender token)");
return false;
}
};
@ -1914,7 +1925,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
return false;
}
};
let room_id = match ensure_hive_space(client, &hive_token).await {
let room_id = match ensure_hive_space(client, &sender_token).await {
Ok(id) => id,
Err(e) => {
tracing::warn!(error = ?e, "matrix: ensure_hive_space failed");
@ -1922,14 +1933,20 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
}
};
// Invite @hive first, then all agents.
if let Err(e) =
invite_to_room(client, &hive_token, &room_id, HIVE_LOCALPART, &server_name).await
if let Err(e) = invite_to_room(
client,
&sender_token,
&room_id,
HIVE_LOCALPART,
&server_name,
)
.await
{
tracing::warn!(error = ?e, "matrix: invite @hive to space failed");
ok = false;
}
for name in agent_names {
if let Err(e) = invite_to_room(client, &hive_token, &room_id, name, &server_name).await {
if let Err(e) = invite_to_room(client, &sender_token, &room_id, name, &server_name).await {
tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed");
ok = false;
}
@ -1940,11 +1957,11 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
// rooms to chat in (Matrix semantics — children aren't auto-joined), so
// without this the Space is empty. The restricted join rule additionally
// lets the operator (a Space member) join from the Space hierarchy.
match ensure_hive_chat_room(client, &hive_token, &room_id, &server_name).await {
match ensure_hive_chat_room(client, &sender_token, &room_id, &server_name).await {
Ok(chat_room_id) => {
if let Err(e) = invite_to_room(
client,
&hive_token,
&sender_token,
&chat_room_id,
HIVE_LOCALPART,
&server_name,
@ -1956,7 +1973,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
}
for name in agent_names {
if let Err(e) =
invite_to_room(client, &hive_token, &chat_room_id, name, &server_name).await
invite_to_room(client, &sender_token, &chat_room_id, name, &server_name).await
{
tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed");
ok = false;

View file

@ -2,7 +2,7 @@
//! `/run/hyperhive` + `/run/hive-agent` runtime roots).
//!
//! Historically these were flat string literals scattered across many
//! modules (`broker.sqlite`, `matrix-hive-token`, `agent-sockets.json`,
//! modules (`broker.sqlite`, `matrix-sender-token`, `agent-sockets.json`,
//! …). This module is the **single Rust-side source** for every host
//! path — the strictly host-side ones grouped into subdirs (`db/`,
//! `forge/`, `matrix/`, `run/`), plus the **nix-coupled** roots
@ -128,7 +128,7 @@ pub fn agent_identity_dir(name: &str) -> PathBuf {
agent_identity_root().join(name)
}
/// `matrix/` — host-side matrix provisioning state (the `@hive:` access token, hive
/// `matrix/` — host-side matrix provisioning state (the appservice sender token, hive
/// Space room id, per-agent password creds). The shared registration
/// token is bind-mounted into the tuwunel container via nix and stays
/// at its own path (tracked separately).
@ -137,9 +137,9 @@ pub fn matrix_dir() -> PathBuf {
state_root().join("matrix")
}
/// `matrix/access-token` — the `@hive:` account's matrix access token.
/// `matrix/access-token` — the matrix appservice sender account's access token.
#[must_use]
pub fn matrix_hive_token() -> PathBuf {
pub fn matrix_sender_token() -> PathBuf {
matrix_dir().join("access-token")
}
@ -327,7 +327,7 @@ pub fn relocate_legacy_state() {
"forge-agent-configs-avatar-set",
forge_config_org_avatar_marker(),
),
("matrix-hive-token", matrix_hive_token()),
("matrix-sender-token", matrix_sender_token()),
("matrix-space-room-id", matrix_space_room_id()),
("matrix-creds", matrix_creds_dir()),
("agent-sockets.json", agent_sockets_file()),

View file

@ -455,7 +455,7 @@ async fn stream_agent_status(
// The `hivectl matrix` subcommands used to run these in-process, which forced
// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …).
// They now run daemon-side over the host socket: the daemon already holds the
// register + `@hive:` tokens and the matrix creds dir. Each op returns the
// register + sender tokens and the matrix creds dir. Each op returns the
// operator-facing lines hivectl used to `println!` in `HostResponse::messages`
// for the client to print verbatim.
// ---------------------------------------------------------------------------
@ -780,7 +780,7 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
crate::matrix::ensure_hive_user(&client, &as_token)
.await
.context("matrix sync-admin")?;
let path = crate::matrix::hive_token_path();
let path = crate::matrix::sender_token_path();
Ok(HostResponse::messages(vec![
format!(
"matrix: the @{}: user is provisioned",
@ -792,12 +792,12 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
require_matrix_present()?;
let hive_token = crate::matrix::read_hive_token()?;
let sender_token = crate::matrix::read_sender_token()?;
let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client)
.await
.context("discover matrix server_name")?;
crate::matrix::promote_user_to_admin(&client, &hive_token, name, &server_name)
crate::matrix::promote_user_to_admin(&client, &sender_token, name, &server_name)
.await
.with_context(|| format!("matrix promote-user {name}"))?;
Ok(HostResponse::messages(vec![format!(
@ -807,12 +807,12 @@ async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
require_matrix_present()?;
let hive_token = crate::matrix::read_hive_token()?;
let sender_token = crate::matrix::read_sender_token()?;
let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client)
.await
.context("discover matrix server_name")?;
let room_id = crate::matrix::invite_user(&client, &hive_token, user, room, &server_name)
let room_id = crate::matrix::invite_user(&client, &sender_token, user, room, &server_name)
.await
.with_context(|| format!("matrix invite {user}"))?;
let target = if user.starts_with('@') {
@ -827,12 +827,12 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResp
async fn handle_matrix_reset_password(name: &str) -> Result<HostResponse> {
require_matrix_present()?;
let hive_token = crate::matrix::read_hive_token()?;
let sender_token = crate::matrix::read_sender_token()?;
let client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client)
.await
.context("discover matrix server_name")?;
crate::matrix::reset_user_password(&client, &hive_token, name, &server_name)
crate::matrix::reset_user_password(&client, &sender_token, name, &server_name)
.await
.with_context(|| format!("matrix reset-password {name}"))?;
// Password is persisted by reset_user_password.

View file

@ -335,7 +335,7 @@ pub enum MatrixCmd {
#[arg(long, conflicts_with = "password")]
password_stdin: bool,
},
/// Provision (or re-provision) the hive's own `@hive:` matrix account.
/// Provision (or re-provision) the matrix appservice's sender account.
///
/// Runs automatically on startup; run manually to recover a missing
/// access token.

View file

@ -1,5 +1,5 @@
//! `hivectl matrix` — matrix account provisioning verbs. hivectl forwards
//! each request to the daemon (which owns the register + `@hive:` tokens and
//! each request to the daemon (which owns the register + sender tokens and
//! the matrix creds dir) and renders the reply; it no longer links the
//! matrix machinery itself.
@ -28,7 +28,7 @@ pub(crate) async fn run_matrix_cmd(socket: &Path, cmd: MatrixCmd) -> Result<()>
/// Send a matrix provisioning request to the daemon and print the
/// operator-facing result lines it returns. The daemon owns the register +
/// `@hive:` tokens and the matrix creds dir, so hivectl no longer links the
/// sender tokens and the matrix creds dir, so hivectl no longer links the
/// matrix machinery — it just forwards the request and renders the reply.
async fn matrix_request(socket: &Path, req: hive_host_sock::HostRequest) -> Result<()> {
let resp = crate::client::request(socket, req)

View file

@ -115,7 +115,7 @@ let
# ── swarm-matrix-minter ────────────────────────────────────────────────
#
# The oneshot that publishes the `@hive:` account's access token to
# The oneshot that publishes the appservice sender account's access token to
# the swarm's secret store. It runs INSIDE the container, beside tuwunel,
# because the appservice token that authorises the mint is already in here —
# `appserviceDir` below is bound read-only precisely so the homeserver can
@ -1392,7 +1392,7 @@ in
"oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}"
];
# Publish the `@hive:` account's access token to the swarm
# Publish the appservice sender account's access token to the swarm
# store, once, under an identity that belongs to this container and
# not to the hive. See `minterActive` above for why it runs here.
#
@ -1403,7 +1403,7 @@ in
# set, because the unit having succeeded is not the idempotency
# record — the store is, and it outlives this machine.
systemd.services.swarm-matrix-minter = lib.mkIf minterActive {
description = "publish the @hive: matrix credential to the swarm secret store";
description = "publish the matrix sender token to the swarm secret store";
# Ordered after the homeserver because both of the ladder's arms
# are client-server API calls. `wants`, not `requires`: a run that
# finds the credential already published never touches tuwunel at

View file

@ -249,7 +249,7 @@ let
# written by the caller — the same trap as the two grants above.
#
# Not `swarm/services/*` like the publisher's: this principal produces
# exactly one secret, the `@hive:` account's access token, and a
# exactly one secret, the appservice sender account's access token, and a
# homeserver is not entitled to overwrite Grafana's OIDC client. The path is
# spelled to the leaf for that reason, not for tidiness.
#
@ -260,7 +260,7 @@ let
# here recovers one secret this principal itself wrote, which is a much
# narrower grant than the publisher's would have been.
matrixMinterPolicyText = ''
path "${credentialMountPath}/data/swarm/services/matrix/hive-access-token" {
path "${credentialMountPath}/data/swarm/services/matrix/sender-token" {
capabilities = ["create", "update", "read"]
}
'';
@ -695,7 +695,7 @@ in
description = ''
Subject the store's matrix-minter cert-auth role accepts the
identity the oneshot inside the matrix container presents when it
publishes the `@hive:` account's access token.
publishes the appservice sender account's access token.
A **third** identity rather than reuse of either sibling above, and
the narrowest of the three: its grant is one path, that

View file

@ -152,12 +152,12 @@ let
# `Kind::label`, which renders the singular for error text. The singular
# spelling evaluates, deploys, and 403s every read with "permission
# denied" and nothing else.
name = "the matrix minter's grant is the hive credential's path and nothing else";
name = "the matrix minter's grant is the sender token's path and nothing else";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script;
in
lib.hasInfix "path \"secret/data/swarm/services/matrix/hive-access-token\" {" s
lib.hasInfix "path \"secret/data/swarm/services/matrix/sender-token\" {" s
&& !(lib.hasInfix "secret/data/swarm/services/*" s)
&& !(lib.hasInfix "secret/data/swarm/agents" s)
&& !(lib.hasInfix "secret/data/swarm/hives" s)

View file

@ -163,12 +163,12 @@ in
# rather than every hive's.
swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder";
# The `@hive:` access token's minter. Out of `daemonBins` for the same
# The appservice sender account's access-token minter. Out of `daemonBins` for
# "runs *inside* a container, not on the host" reason as the responder
# above, and with a second one: putting it in the core bundle would place
# the binary that reads the appservice token on every hive's filesystem,
# which is the arrangement it exists to end.
swarm-matrix-minter = mkBinPackage "swarm-matrix-minter" "hyperhive matrix @hive: access-token minter";
swarm-matrix-minter = mkBinPackage "swarm-matrix-minter" "hyperhive matrix appservice sender-token minter";
# The only process allowed to write swarm-authelia's users database —
# same "runs *inside* a container, not on the host" placement as

View file

@ -1,7 +1,7 @@
# swarm-matrix-minter
A boot-time oneshot that runs **inside `containers.hive-matrix`**, beside the
homeserver, and puts the `@hive:` account's access token into the swarm's secret
homeserver, and puts the appservice sender account's access token into the swarm's secret
store under an identity of its own.
## Why it lives in the matrix container
@ -21,7 +21,7 @@ is a property of the thing being minted.
## Idempotency
The **store** is the key, not the homeserver. A run reads
`swarm/services/matrix/hive-access-token` first and returns without touching the
`swarm/services/matrix/sender-token` first and returns without touching the
homeserver when something is already there. Only an empty path reaches the mint
ladder:

View file

@ -4,7 +4,7 @@
//! its `errcode`, never its body.** A successful `/register` or `/login` body
//! *is* an access token, and an error body is one malformed response away from
//! being the same bytes — so a `body: {json}` in a message here would put the
//! `@hive:` credential in the journal.
//! sender token in the journal.
use anyhow::{Context, Result, bail};

View file

@ -1,4 +1,4 @@
//! Mint the `@hive:` account's homeserver access token, once, and publish it to the
//! Mint the matrix appservice sender account's homeserver access token, once, and publish it to the
//! swarm's secret store.
//!
//! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the
@ -137,9 +137,9 @@ async fn main() -> Result<()> {
)
})?;
let path = matrix::hive_token_path();
let path = matrix::sender_token_path();
if already_published(&store, &path).await {
tracing::info!(%path, "the @hive: credential is already published; not minting");
tracing::info!(%path, "the sender token is already published; not minting");
return Ok(());
}
@ -167,8 +167,8 @@ async fn main() -> Result<()> {
},
)
.await
.with_context(|| format!("writing the @hive: credential to {path}"))?;
tracing::info!(%path, "published the @hive: credential");
.with_context(|| format!("writing the sender token to {path}"))?;
tracing::info!(%path, "published the sender token");
Ok(())
}
@ -242,8 +242,8 @@ mod tests {
// and names the literal so a move of the path is a deliberate edit on
// both sides rather than a silent 404 on the reading one.
assert_eq!(
matrix::hive_token_path(),
"swarm/services/matrix/hive-access-token"
matrix::sender_token_path(),
"swarm/services/matrix/sender-token"
);
}
}

View file

@ -34,10 +34,12 @@ pub fn account_path(agent: &str, account: &str) -> Result<String, Error> {
/// "only once" without a lock.
pub const HOMESERVER_SERVICE: &str = "matrix";
/// The path holding the `@hive:` account's homeserver access token.
/// The path holding the matrix appservice sender account's homeserver
/// access token.
///
/// Keyed per **homeserver**, not per hive and not per agent: a homeserver has one
/// `@hive:` account (hive-c0re's `matrix::HIVE_LOCALPART`), so a
/// appservice registration and so one sender account (`@hive:<server_name>`,
/// hive-c0re's `matrix::HIVE_LOCALPART`), so a
/// per-hive copy would be several names for one secret. That is also why this
/// takes no argument and cannot fail — there is no caller-supplied segment in
/// it to reject.
@ -45,9 +47,9 @@ pub const HOMESERVER_SERVICE: &str = "matrix";
/// Reachable by every hive without a new grant: [`crate::policy::render`]
/// already grants a hive read on the whole [`Kind::Service`] tree.
#[must_use]
pub fn hive_token_path() -> String {
pub fn sender_token_path() -> String {
format!(
"{ROOT}/{}/{HOMESERVER_SERVICE}/hive-access-token",
"{ROOT}/{}/{HOMESERVER_SERVICE}/sender-token",
<&str>::from(Kind::Service)
)
}
@ -121,7 +123,7 @@ mod tests {
// segment is PLURAL; `Kind::label` renders the singular and is for
// error text only, so reading it as the path segment produces a
// 403 the store explains as "permission denied" and nothing else.
assert_eq!(hive_token_path(), "swarm/services/matrix/hive-access-token");
assert_eq!(sender_token_path(), "swarm/services/matrix/sender-token");
}
#[test]
@ -131,7 +133,7 @@ mod tests {
// neighbouring tree instead of this one.
let prefix =
principal_prefix(Kind::Service, HOMESERVER_SERVICE).expect("a plain name is legal");
assert_eq!(hive_token_path(), format!("{prefix}/hive-access-token"));
assert_eq!(sender_token_path(), format!("{prefix}/sender-token"));
}
#[test]