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 ### 6 · Matrix
```bash ```bash
# Ensure the hive's own `@hive:` account exists first # Ensure the appservice's sender account exists first
hivectl matrix sync-admin hivectl matrix sync-admin
# Provision ruth's own matrix account — same bootstrap-bypass reasoning # 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 - **No forge admin token is stored in any agent state dir.** Agents
hold a regular agent token in their `forge-token` file; sensitive 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 - All config changes (forge PRs on `agent-configs/<name>`) go through
operator approval — agents can't unilaterally rebuild containers, by design. operator approval — agents can't unilaterally rebuild containers, by design.
See [`boundary.md`](../trust-boundary/boundary.md) and [`security.md`](../trust-boundary/security.md). 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) (`hive-matrix-daemon.path` watching for `matrix-token` appearance)
brings the daemon up on the same boot cycle anyway. 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 `@hive:<server_name>` is the appservice's own `sender_localpart`, which
the homeserver creates itself when it loads the registration — on a 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 anything. It's an **ordinary account**: nothing promotes it, and the
homeserver runs no `admin_execute` for it. 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 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 Space and the chat room, writing their hierarchy and join rules, and
inviting agents into them are all ordinary client calls that ride on 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 - **The per-agent sweep honours existing token files.** It skips any
agent that already has a `matrix-token`, so it re-registers no account agent that already has a `matrix-token`, so it re-registers no account
and displaces no session. 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 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 homeserver no longer promotes it, so a hive built fresh has an
ordinary account and an older one keeps whatever standing it acquired. ordinary account and an older one keeps whatever standing it acquired.

View file

@ -54,15 +54,15 @@ strategy for every credential, including the mTLS leaf.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
| store path | minter | reader — pulls at runtime, holds in memory | renewal | | 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>/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/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>/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/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/<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 | | _(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 --> <!-- 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:** ###### **Subcommands:**
* `create-user` — Create or refresh the matrix account + access token for `<name>` * `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 * `promote-user` — Promote a matrix user to homeserver admin
* `reset-password` — Reset a matrix user's password via the admin API * `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 * `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` ## `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. 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 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 # 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 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 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 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 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 - `create-user`: for agents, persists the `access_token` to
`<state>/matrix-token`. Skips registration when the file already `<state>/matrix-token`. Skips registration when the file already
exists — delete it first to force re-registration. 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 (the account `hive-c0re` provisions rooms with). Token persisted to the
access token path. Safe to run again — idempotent. access token path. Safe to run again — idempotent.
- `promote-user`: promotes an already-registered user to homeserver - `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. credentials.
- `invite`: invites a matrix user (full `@user:server` or a bare - `invite`: invites a matrix user (full `@user:server` or a bare
localpart, qualified with the homeserver's `server_name`) to the hive 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 token; the account must be a member of the target room with
invite power (it owns the hive Space, so that case always works). invite power (it owns the hive Space, so that case always works).
Idempotent — already-member / already-invited is a no-op. 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 = const HIVE_CHAT_ROOM_TOPIC: &str =
"Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re."; "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. /// purgeable path — not deleted by `destroy --purge` on any agent.
#[must_use] #[must_use]
pub fn hive_token_path() -> PathBuf { pub fn sender_token_path() -> PathBuf {
crate::paths::matrix_hive_token() crate::paths::matrix_sender_token()
} }
/// Token file inside the agent's bind-mounted state dir (visible as /// 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 /// but the password file is absent — covers the case where agent state dirs
/// were wiped but the homeserver still has the accounts. /// were wiped but the homeserver still has the accounts.
async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result<String> { async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result<String> {
let hive_token = read_hive_token() let sender_token = read_sender_token()
.context("matrix: the @hive: access token is unavailable for auto-recovery")?; .context("matrix: the matrix sender token is unavailable for auto-recovery")?;
let server_name = discover_server_name(client) let server_name = discover_server_name(client)
.await .await
.context("matrix: discover_server_name for auto-recovery")?; .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 .await
.with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?; .with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?;
tracing::info!(%name, "matrix: auto-recovered password via admin-room reset"); 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. /// Look up the room ID for the `#admins:<server>` alias.
async fn discover_admin_room_id( async fn discover_admin_room_id(
client: &reqwest::Client, client: &reqwest::Client,
hive_token: &str, sender_token: &str,
server_name: &str, server_name: &str,
) -> Result<String> { ) -> Result<String> {
let base = matrix_base()?; 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 url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}");
let resp = client let resp = client
.get(&url) .get(&url)
.bearer_auth(hive_token) .bearer_auth(sender_token)
.send() .send()
.await .await
.context("matrix: GET admin room alias")?; .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. /// Generic over `T` so both password-returning and `()` callers share the loop.
async fn admin_room_send_and_poll<T>( async fn admin_room_send_and_poll<T>(
client: &reqwest::Client, client: &reqwest::Client,
hive_token: &str, sender_token: &str,
server_name: &str, server_name: &str,
room_url: &str, room_url: &str,
command: &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}"); format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}");
let send_resp = client let send_resp = client
.put(&send_url) .put(&send_url)
.bearer_auth(hive_token) .bearer_auth(sender_token)
.json(&serde_json::json!({"msgtype": "m.text", "body": command})) .json(&serde_json::json!({"msgtype": "m.text", "body": command}))
.send() .send()
.await .await
@ -601,7 +601,7 @@ async fn admin_room_send_and_poll<T>(
tokio::time::sleep(std::time::Duration::from_secs(1)).await; tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let poll_json = client let poll_json = client
.get(&poll_url) .get(&poll_url)
.bearer_auth(hive_token) .bearer_auth(sender_token)
.send() .send()
.await .await
.context("matrix: admin room poll")? .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. /// Returns the new password; caller is responsible for persisting it.
async fn admin_room_reset_password( async fn admin_room_reset_password(
client: &reqwest::Client, client: &reqwest::Client,
hive_token: &str, sender_token: &str,
server_name: &str, server_name: &str,
localpart: &str, localpart: &str,
) -> Result<String> { ) -> 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 room_url = encode_room_id_for_url(&room_id);
let command = format!("!admin users reset-password @{localpart}:{server_name}"); let command = format!("!admin users reset-password @{localpart}:{server_name}");
admin_room_send_and_poll( admin_room_send_and_poll(
client, client,
hive_token, sender_token,
server_name, server_name,
&room_url, &room_url,
&command, &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 /// 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 /// **Nothing here depends on registration order, and nothing here is
/// privileged.** The account used to have to be the first ever /// privileged.** The account used to have to be the first ever
@ -916,16 +916,16 @@ pub async fn sync_agent_standalone(name: &str) {
/// minter. /// minter.
pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> { pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let path = hive_token_path(); let path = sender_token_path();
if path.exists() if path.exists()
&& let Ok(existing) = std::fs::read_to_string(&path) && let Ok(existing) = std::fs::read_to_string(&path)
&& !existing.trim().is_empty() && !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(()); return Ok(());
} }
if let Some(token) = stored_hive_token().await { if let Some(token) = stored_sender_token().await {
return persist_hive_token(&path, &token); return persist_sender_token(&path, &token);
} }
let password = random_password()?; let password = random_password()?;
let access_token = match register_user(client, HIVE_LOCALPART, as_token, &password).await { 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), 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. /// directory if it is not there.
/// ///
/// Shared by both arms of [`ensure_hive_user`] rather than duplicated into /// 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 /// 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 /// reader off the hive's matrix credential, and a second copy of that decision
/// is one that can be edited alone. /// 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; use std::os::unix::fs::PermissionsExt;
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok(); std::fs::create_dir_all(parent).ok();
} }
std::fs::write(path, format!("{access_token}\n")).with_context(|| { std::fs::write(path, format!("{access_token}\n"))
format!( .with_context(|| format!("matrix: write the sender token to {}", path.display()))?;
"matrix: write the @hive: access token to {}",
path.display()
)
})?;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); 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(()) 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. /// this hive's own store identity.
/// ///
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` — /// 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. /// sweep.
/// ///
/// 🩸 Logs the store **path** and never the value. /// 🩸 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") let hive = std::env::var("HYPERHIVE_HIVE_NAME")
.ok() .ok()
.filter(|h| !h.is_empty())?; .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 { let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
Ok(store) => store, Ok(store) => store,
Err(e) => { 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; return None;
} }
}; };
@ -1034,15 +1030,15 @@ async fn stored_hive_token() -> Option<String> {
.await .await
{ {
Ok(credential) if !credential.value.trim().is_empty() => { 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) Some(credential.value)
} }
Ok(_) => { 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 None
} }
Err(e) => { 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 None
} }
} }
@ -1116,16 +1112,16 @@ mod is_make_admin_success_tests {
/// intended long-term mechanism, not a stopgap awaiting an upstream fix. /// intended long-term mechanism, not a stopgap awaiting an upstream fix.
pub async fn promote_user_to_admin( pub async fn promote_user_to_admin(
client: &reqwest::Client, client: &reqwest::Client,
hive_token: &str, sender_token: &str,
localpart: &str, localpart: &str,
server_name: &str, server_name: &str,
) -> Result<()> { ) -> 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 room_url = encode_room_id_for_url(&room_id);
let command = format!("!admin users make-user-admin @{localpart}:{server_name}"); let command = format!("!admin users make-user-admin @{localpart}:{server_name}");
admin_room_send_and_poll( admin_room_send_and_poll(
client, client,
hive_token, sender_token,
server_name, server_name,
&room_url, &room_url,
&command, &command,
@ -1155,11 +1151,11 @@ pub async fn promote_user_to_admin(
/// Returns the new password for use in subsequent `login_user` calls. /// Returns the new password for use in subsequent `login_user` calls.
pub async fn reset_user_password( pub async fn reset_user_password(
client: &reqwest::Client, client: &reqwest::Client,
hive_token: &str, sender_token: &str,
localpart: &str, localpart: &str,
server_name: &str, server_name: &str,
) -> Result<String> { ) -> 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 .await
.with_context(|| { .with_context(|| {
format!("matrix: admin-room password reset for @{localpart}:{server_name}") 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. /// is absent — callers should gate their homeserver calls on this.
pub fn read_hive_token() -> Result<String> { pub fn read_sender_token() -> Result<String> {
let path = hive_token_path(); let path = sender_token_path();
std::fs::read_to_string(&path) std::fs::read_to_string(&path)
.ok() .ok()
.map(|s| s.trim().to_owned()) .map(|s| s.trim().to_owned())
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.with_context(|| { .with_context(|| {
format!( 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 \ ensure hive-c0re has started at least once with matrix enabled \
(it provisions the @hive: account on boot)", (it provisions the @hive: account on boot)",
path.display() 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 /// Name-based (not alias-based) rediscovery keeps the Space free of any
/// special-char room alias — the hardcoded plain name is the anchor. /// 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 base = matrix_http()?;
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
let joined: serde_json::Value = client let joined: serde_json::Value = client
.get(&joined_url) .get(&joined_url)
.bearer_auth(hive_token) .bearer_auth(sender_token)
.send() .send()
.await .await
.ok()? .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); let encoded = encode_room_id_for_url(room_id);
// Must be an m.space (m.room.create `type`). // Must be an m.space (m.room.create `type`).
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); 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 Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>() .json::<serde_json::Value>()
.await .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`). // …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_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 Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>() .json::<serde_json::Value>()
.await .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, /// Returns an error if the homeserver is unreachable, `createRoom` fails,
/// or the room-ID file cannot be written. /// 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()?; let base = matrix_base()?;
// 1. Stored room id wins (fast path). // 1. Stored room id wins (fast path).
if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_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 // 2. No stored id — rediscover the existing space by its hardcoded name
// before creating a new one (prevents duplicate spaces after a wipe). // 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)?; persist_space_room_id(&room_id)?;
tracing::info!(%room_id, "matrix: recovered hive space by name"); tracing::info!(%room_id, "matrix: recovered hive space by name");
return Ok(room_id); return Ok(room_id);
@ -1341,7 +1342,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, hive_token: &str) -> Re
}); });
let resp = client let resp = client
.post(&url) .post(&url)
.bearer_auth(hive_token) .bearer_auth(sender_token)
.json(&body) .json(&body)
.send() .send()
.await .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 /// [`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 /// room instead of spawning a duplicate. `None` if the homeserver is
/// unreachable or no match exists. /// 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 base = matrix_http()?;
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
let joined: serde_json::Value = client let joined: serde_json::Value = client
.get(&joined_url) .get(&joined_url)
.bearer_auth(hive_token) .bearer_auth(sender_token)
.send() .send()
.await .await
.ok()? .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); let encoded = encode_room_id_for_url(room_id);
// Skip the Space itself (and any other m.space). // Skip the Space itself (and any other m.space).
let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); 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 Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>() .json::<serde_json::Value>()
.await .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`). // …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_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 Ok(r) if r.status().is_success() => r
.json::<serde_json::Value>() .json::<serde_json::Value>()
.await .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. /// too, loudly — and a 404 is the expected first-setup case.
async fn current_room_state( async fn current_room_state(
client: &reqwest::Client, client: &reqwest::Client,
hive_token: &str, sender_token: &str,
url: &str, url: &str,
) -> Option<serde_json::Value> { ) -> 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, Ok(resp) => resp,
Err(e) => { Err(e) => {
tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing"); 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**. /// write when the room already carries identical content**.
/// ///
/// The read is not an optimisation. A PUT of identical content is a no-op /// 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. /// missing or divergent link still gets written.
async fn set_room_state( async fn set_room_state(
client: &reqwest::Client, client: &reqwest::Client,
hive_token: &str, sender_token: &str,
room_id: &str, room_id: &str,
event_type: &str, event_type: &str,
state_key: &str, state_key: &str,
@ -1511,7 +1517,7 @@ async fn set_room_state(
let encoded_key = encode_room_id_for_url(state_key); let encoded_key = encode_room_id_for_url(state_key);
let url = let url =
format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}"); 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) { if !state_needs_write(current.as_ref(), content) {
tracing::debug!( tracing::debug!(
%room_id, %room_id,
@ -1522,7 +1528,7 @@ async fn set_room_state(
} }
let resp = client let resp = client
.put(&url) .put(&url)
.bearer_auth(hive_token) .bearer_auth(sender_token)
.json(content) .json(content)
.send() .send()
.await .await
@ -1559,7 +1565,7 @@ async fn set_room_state(
/// link is logged but not fatal (the room still exists + is joinable). /// link is logged but not fatal (the room still exists + is joinable).
pub async fn ensure_hive_chat_room( pub async fn ensure_hive_chat_room(
client: &reqwest::Client, client: &reqwest::Client,
hive_token: &str, sender_token: &str,
space_room_id: &str, space_room_id: &str,
server_name: &str, server_name: &str,
) -> Result<String> { ) -> Result<String> {
@ -1573,7 +1579,7 @@ pub async fn ensure_hive_chat_room(
{ {
tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned"); tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned");
id 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)?; persist_chat_room_id(&id)?;
tracing::info!(room_id = %id, "matrix: recovered hive chat room by name"); tracing::info!(room_id = %id, "matrix: recovered hive chat room by name");
id id
@ -1613,7 +1619,7 @@ pub async fn ensure_hive_chat_room(
}); });
let resp = client let resp = client
.post(&url) .post(&url)
.bearer_auth(hive_token) .bearer_auth(sender_token)
.json(&body) .json(&body)
.send() .send()
.await .await
@ -1644,7 +1650,7 @@ pub async fn ensure_hive_chat_room(
}); });
if let Err(e) = set_room_state( if let Err(e) = set_room_state(
client, client,
hive_token, sender_token,
space_room_id, space_room_id,
"m.space.child", "m.space.child",
&room_id, &room_id,
@ -1662,21 +1668,21 @@ pub async fn ensure_hive_chat_room(
/// account. Idempotent — treats already-member responses as success. /// account. Idempotent — treats already-member responses as success.
async fn invite_to_room( async fn invite_to_room(
client: &reqwest::Client, client: &reqwest::Client,
hive_token: &str, sender_token: &str,
room_id: &str, room_id: &str,
localpart: &str, localpart: &str,
server_name: &str, server_name: &str,
) -> Result<()> { ) -> Result<()> {
let user_id = format!("@{localpart}:{server_name}"); 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 /// `None` if there is no membership event (never invited) or the lookup
/// fails. Returns the raw membership string (`invite`, `join`, `leave`, …). /// fails. Returns the raw membership string (`invite`, `join`, `leave`, …).
async fn room_membership( async fn room_membership(
client: &reqwest::Client, client: &reqwest::Client,
hive_token: &str, sender_token: &str,
encoded_room_id: &str, encoded_room_id: &str,
user_id: &str, user_id: &str,
) -> Option<String> { ) -> Option<String> {
@ -1687,7 +1693,12 @@ async fn room_membership(
let url = format!( let url = format!(
"{base}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}" "{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() { if !resp.status().is_success() {
// 404 = no membership event yet; anything else we treat as "unknown" // 404 = no membership event yet; anything else we treat as "unknown"
// and let the caller fall through to the invite attempt. // 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` /// 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, /// 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` /// so they are not re-notified), and a 403 `M_FORBIDDEN` / `M_BAD_STATE`
/// from a racing invite is still treated as success. /// from a racing invite is still treated as success.
async fn invite_user_id( async fn invite_user_id(
client: &reqwest::Client, client: &reqwest::Client,
hive_token: &str, sender_token: &str,
room_id: &str, room_id: &str,
user_id: &str, user_id: &str,
) -> Result<()> { ) -> Result<()> {
@ -1716,7 +1727,7 @@ async fn invite_user_id(
// Skip the invite entirely when the user is already invited or joined. // 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, // Re-POSTing an invite to a pending member re-sends the invite event,
// which re-notifies the agent on every provisioning sweep. // 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") && matches!(membership.as_str(), "invite" | "join")
{ {
tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)"); 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 url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite");
let resp = client let resp = client
.post(&url) .post(&url)
.bearer_auth(hive_token) .bearer_auth(sender_token)
.json(&serde_json::json!({ "user_id": user_id })) .json(&serde_json::json!({ "user_id": user_id }))
.send() .send()
.await .await
@ -1759,13 +1770,13 @@ async fn invite_user_id(
/// ///
/// # Errors /// # 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 /// 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 /// space), or the invite POST fails for a reason other than the user
/// already being a member / invited. /// already being a member / invited.
pub async fn invite_user( pub async fn invite_user(
client: &reqwest::Client, client: &reqwest::Client,
hive_token: &str, sender_token: &str,
user: &str, user: &str,
room_override: Option<&str>, room_override: Option<&str>,
server_name: &str, server_name: &str,
@ -1779,7 +1790,7 @@ pub async fn invite_user(
// Resolve the room: explicit override (id or #alias) wins; otherwise // Resolve the room: explicit override (id or #alias) wins; otherwise
// the persisted hive Space. // the persisted hive Space.
let room_id = match room_override { 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) if r.starts_with('!') => r.to_owned(),
Some(r) => anyhow::bail!( Some(r) => anyhow::bail!(
"matrix: --room {r:?} is neither a room id nor an alias; \ "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)", (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) Ok(room_id)
} }
/// Resolve a `#alias:server` to its room id via the directory API. /// Resolve a `#alias:server` to its room id via the directory API.
async fn resolve_room_alias( async fn resolve_room_alias(
client: &reqwest::Client, client: &reqwest::Client,
hive_token: &str, sender_token: &str,
alias: &str, alias: &str,
) -> Result<String> { ) -> Result<String> {
let base = matrix_base()?; let base = matrix_base()?;
@ -1808,7 +1819,7 @@ async fn resolve_room_alias(
let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}"); let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}");
let resp = client let resp = client
.get(&url) .get(&url)
.bearer_auth(hive_token) .bearer_auth(sender_token)
.send() .send()
.await .await
.with_context(|| format!("matrix: GET directory for {alias}"))?; .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 { async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool {
let mut ok = true; let mut ok = true;
// server_name is needed to form full Matrix user IDs for invites. // 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, Ok(t) => t,
Err(e) => { 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; return false;
} }
}; };
@ -1914,7 +1925,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
return false; 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, Ok(id) => id,
Err(e) => { Err(e) => {
tracing::warn!(error = ?e, "matrix: ensure_hive_space failed"); 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. // Invite @hive first, then all agents.
if let Err(e) = if let Err(e) = invite_to_room(
invite_to_room(client, &hive_token, &room_id, HIVE_LOCALPART, &server_name).await client,
&sender_token,
&room_id,
HIVE_LOCALPART,
&server_name,
)
.await
{ {
tracing::warn!(error = ?e, "matrix: invite @hive to space failed"); tracing::warn!(error = ?e, "matrix: invite @hive to space failed");
ok = false; ok = false;
} }
for name in agent_names { 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"); tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed");
ok = false; 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 // rooms to chat in (Matrix semantics — children aren't auto-joined), so
// without this the Space is empty. The restricted join rule additionally // without this the Space is empty. The restricted join rule additionally
// lets the operator (a Space member) join from the Space hierarchy. // 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) => { Ok(chat_room_id) => {
if let Err(e) = invite_to_room( if let Err(e) = invite_to_room(
client, client,
&hive_token, &sender_token,
&chat_room_id, &chat_room_id,
HIVE_LOCALPART, HIVE_LOCALPART,
&server_name, &server_name,
@ -1956,7 +1973,7 @@ async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bo
} }
for name in agent_names { for name in agent_names {
if let Err(e) = 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"); tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed");
ok = false; ok = false;

View file

@ -2,7 +2,7 @@
//! `/run/hyperhive` + `/run/hive-agent` runtime roots). //! `/run/hyperhive` + `/run/hive-agent` runtime roots).
//! //!
//! Historically these were flat string literals scattered across many //! 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 //! …). This module is the **single Rust-side source** for every host
//! path — the strictly host-side ones grouped into subdirs (`db/`, //! path — the strictly host-side ones grouped into subdirs (`db/`,
//! `forge/`, `matrix/`, `run/`), plus the **nix-coupled** roots //! `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) 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 /// Space room id, per-agent password creds). The shared registration
/// token is bind-mounted into the tuwunel container via nix and stays /// token is bind-mounted into the tuwunel container via nix and stays
/// at its own path (tracked separately). /// at its own path (tracked separately).
@ -137,9 +137,9 @@ pub fn matrix_dir() -> PathBuf {
state_root().join("matrix") 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] #[must_use]
pub fn matrix_hive_token() -> PathBuf { pub fn matrix_sender_token() -> PathBuf {
matrix_dir().join("access-token") matrix_dir().join("access-token")
} }
@ -327,7 +327,7 @@ pub fn relocate_legacy_state() {
"forge-agent-configs-avatar-set", "forge-agent-configs-avatar-set",
forge_config_org_avatar_marker(), 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-space-room-id", matrix_space_room_id()),
("matrix-creds", matrix_creds_dir()), ("matrix-creds", matrix_creds_dir()),
("agent-sockets.json", agent_sockets_file()), ("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 `hivectl matrix` subcommands used to run these in-process, which forced
// the standalone CLI to link the whole daemon crate (matrix-sdk, reqwest, …). // 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 // 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` // operator-facing lines hivectl used to `println!` in `HostResponse::messages`
// for the client to print verbatim. // 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) crate::matrix::ensure_hive_user(&client, &as_token)
.await .await
.context("matrix sync-admin")?; .context("matrix sync-admin")?;
let path = crate::matrix::hive_token_path(); let path = crate::matrix::sender_token_path();
Ok(HostResponse::messages(vec![ Ok(HostResponse::messages(vec![
format!( format!(
"matrix: the @{}: user is provisioned", "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> { async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
require_matrix_present()?; 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 client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client) let server_name = crate::matrix::discover_server_name(&client)
.await .await
.context("discover matrix server_name")?; .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 .await
.with_context(|| format!("matrix promote-user {name}"))?; .with_context(|| format!("matrix promote-user {name}"))?;
Ok(HostResponse::messages(vec![format!( 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> { async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
require_matrix_present()?; 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 client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client) let server_name = crate::matrix::discover_server_name(&client)
.await .await
.context("discover matrix server_name")?; .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 .await
.with_context(|| format!("matrix invite {user}"))?; .with_context(|| format!("matrix invite {user}"))?;
let target = if user.starts_with('@') { 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> { async fn handle_matrix_reset_password(name: &str) -> Result<HostResponse> {
require_matrix_present()?; 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 client = matrix_http_client()?;
let server_name = crate::matrix::discover_server_name(&client) let server_name = crate::matrix::discover_server_name(&client)
.await .await
.context("discover matrix server_name")?; .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 .await
.with_context(|| format!("matrix reset-password {name}"))?; .with_context(|| format!("matrix reset-password {name}"))?;
// Password is persisted by reset_user_password. // Password is persisted by reset_user_password.

View file

@ -335,7 +335,7 @@ pub enum MatrixCmd {
#[arg(long, conflicts_with = "password")] #[arg(long, conflicts_with = "password")]
password_stdin: bool, 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 /// Runs automatically on startup; run manually to recover a missing
/// access token. /// access token.

View file

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

View file

@ -115,7 +115,7 @@ let
# ── swarm-matrix-minter ──────────────────────────────────────────────── # ── 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, # the swarm's secret store. It runs INSIDE the container, beside tuwunel,
# because the appservice token that authorises the mint is already in here — # because the appservice token that authorises the mint is already in here —
# `appserviceDir` below is bound read-only precisely so the homeserver can # `appserviceDir` below is bound read-only precisely so the homeserver can
@ -1392,7 +1392,7 @@ in
"oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}" "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 # store, once, under an identity that belongs to this container and
# not to the hive. See `minterActive` above for why it runs here. # 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 # set, because the unit having succeeded is not the idempotency
# record — the store is, and it outlives this machine. # record — the store is, and it outlives this machine.
systemd.services.swarm-matrix-minter = lib.mkIf minterActive { 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 # Ordered after the homeserver because both of the ladder's arms
# are client-server API calls. `wants`, not `requires`: a run that # are client-server API calls. `wants`, not `requires`: a run that
# finds the credential already published never touches tuwunel at # 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. # written by the caller — the same trap as the two grants above.
# #
# Not `swarm/services/*` like the publisher's: this principal produces # 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 # homeserver is not entitled to overwrite Grafana's OIDC client. The path is
# spelled to the leaf for that reason, not for tidiness. # 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 # here recovers one secret this principal itself wrote, which is a much
# narrower grant than the publisher's would have been. # narrower grant than the publisher's would have been.
matrixMinterPolicyText = '' matrixMinterPolicyText = ''
path "${credentialMountPath}/data/swarm/services/matrix/hive-access-token" { path "${credentialMountPath}/data/swarm/services/matrix/sender-token" {
capabilities = ["create", "update", "read"] capabilities = ["create", "update", "read"]
} }
''; '';
@ -695,7 +695,7 @@ in
description = '' description = ''
Subject the store's matrix-minter cert-auth role accepts the Subject the store's matrix-minter cert-auth role accepts the
identity the oneshot inside the matrix container presents when it 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 A **third** identity rather than reuse of either sibling above, and
the narrowest of the three: its grant is one path, that 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 # `Kind::label`, which renders the singular for error text. The singular
# spelling evaluates, deploys, and 403s every read with "permission # spelling evaluates, deploys, and 403s every read with "permission
# denied" and nothing else. # 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 = ok =
let let
s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script; s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script;
in 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/services/*" s)
&& !(lib.hasInfix "secret/data/swarm/agents" s) && !(lib.hasInfix "secret/data/swarm/agents" s)
&& !(lib.hasInfix "secret/data/swarm/hives" s) && !(lib.hasInfix "secret/data/swarm/hives" s)

View file

@ -163,12 +163,12 @@ in
# rather than every hive's. # rather than every hive's.
swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder"; 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 # "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 # 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, # the binary that reads the appservice token on every hive's filesystem,
# which is the arrangement it exists to end. # 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 — # The only process allowed to write swarm-authelia's users database —
# same "runs *inside* a container, not on the host" placement as # same "runs *inside* a container, not on the host" placement as

View file

@ -1,7 +1,7 @@
# swarm-matrix-minter # swarm-matrix-minter
A boot-time oneshot that runs **inside `containers.hive-matrix`**, beside the 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. store under an identity of its own.
## Why it lives in the matrix container ## Why it lives in the matrix container
@ -21,7 +21,7 @@ is a property of the thing being minted.
## Idempotency ## Idempotency
The **store** is the key, not the homeserver. A run reads 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 homeserver when something is already there. Only an empty path reaches the mint
ladder: ladder:

View file

@ -4,7 +4,7 @@
//! its `errcode`, never its body.** A successful `/register` or `/login` body //! 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 //! *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 //! 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}; 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. //! swarm's secret store.
//! //!
//! A oneshot inside `containers.hive-matrix`, not a daemon and not part of the //! 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 { 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(()); return Ok(());
} }
@ -167,8 +167,8 @@ async fn main() -> Result<()> {
}, },
) )
.await .await
.with_context(|| format!("writing the @hive: credential to {path}"))?; .with_context(|| format!("writing the sender token to {path}"))?;
tracing::info!(%path, "published the @hive: credential"); tracing::info!(%path, "published the sender token");
Ok(()) Ok(())
} }
@ -242,8 +242,8 @@ mod tests {
// and names the literal so a move of the path is a deliberate edit on // 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. // both sides rather than a silent 404 on the reading one.
assert_eq!( assert_eq!(
matrix::hive_token_path(), matrix::sender_token_path(),
"swarm/services/matrix/hive-access-token" "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. /// "only once" without a lock.
pub const HOMESERVER_SERVICE: &str = "matrix"; 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 /// 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 /// 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 /// takes no argument and cannot fail — there is no caller-supplied segment in
/// it to reject. /// it to reject.
@ -45,9 +47,9 @@ pub const HOMESERVER_SERVICE: &str = "matrix";
/// Reachable by every hive without a new grant: [`crate::policy::render`] /// Reachable by every hive without a new grant: [`crate::policy::render`]
/// already grants a hive read on the whole [`Kind::Service`] tree. /// already grants a hive read on the whole [`Kind::Service`] tree.
#[must_use] #[must_use]
pub fn hive_token_path() -> String { pub fn sender_token_path() -> String {
format!( format!(
"{ROOT}/{}/{HOMESERVER_SERVICE}/hive-access-token", "{ROOT}/{}/{HOMESERVER_SERVICE}/sender-token",
<&str>::from(Kind::Service) <&str>::from(Kind::Service)
) )
} }
@ -121,7 +123,7 @@ mod tests {
// segment is PLURAL; `Kind::label` renders the singular and is for // segment is PLURAL; `Kind::label` renders the singular and is for
// error text only, so reading it as the path segment produces a // error text only, so reading it as the path segment produces a
// 403 the store explains as "permission denied" and nothing else. // 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] #[test]
@ -131,7 +133,7 @@ mod tests {
// neighbouring tree instead of this one. // neighbouring tree instead of this one.
let prefix = let prefix =
principal_prefix(Kind::Service, HOMESERVER_SERVICE).expect("a plain name is legal"); 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] #[test]