OXYGENHUB

Notifications that reach you, and 2FA without the password twice bff9106edcba

Andy (2026-10-14) wanted the usual notifications for oxygenhub.io. Three
things stopped them: the agents act as Andy and an actor is never told
(331 of last week's proposals), he watched none of his repositories, and
Diffusion's proposals told nobody.

- Tokens marked as an agent's (Settings › Tokens, migration 0065): a
  request with one runs with its name in notify::AGENT, and its events
  reach the owner, shown as 'andymccall (agent token Claude)'.
- Owners watch their repositories by default; unwatching mutes
  (watches.muted); watcher counts include them.
- Diffusion's proposals are told as web ones are.
- flow.fixed when a bookmark is green again (the run before failed, or
  this run failed and a job was rerun).
- Backups: failures email too (they were inbox only), backup.recovered
  when whole again, and backup.summary once a day from 07:00 UTC.
- Administrators hear of each new account (account.registered).
- notify::tell sends any notice to the inbox and, where chosen, email.

Sign-in with 2FA (#184): the password step hands the code step a signed
proof (five minutes, void once the session salt changes), so the code
step asks for the code only; a forged or stale proof starts again.

Tested: owners told by default and not once muted; an agent token's
issue told to its owner, a plain token's not; sign-up told to the admin;
failed then fixed; backup recovered and summarised; the code step has
no password field and refuses a forged proof.

closes #183, closes #184
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-14 12:13 UTC
Signature
✔ signed by Andy McCall
Hash
bff9106edcba4b233102d8bbe226a6c207daca782691ab032f70368d2419e517
Depends on
070a7b64b368 1553bfc9ae53 18e381ea88c2 19bb31ff1f5c 1b3a00986a5a 2e9188ca4f98 33bd0bd45f8a 34beb1a7e376 39442b000e6b 42fa95540468 4a4400c5812d 60f11b71a740 83940d53664f 93be9ce4653e a7e6e8e6094c d23464c0ac60 d7d182467382 e8fa53694f68 eb0c44a9ad37 f677d6270298
Depended on by
a22e4aec2e28 1000b40a2629 33d98147511b e57765dee61a d4a0043fded4 3b1bc27fe805 5d90e0e045e2 2cbf6f348595 7e01a7665aa5 aa0bfbd11f02 9a8e3b4419e0 00ef7b83b340 b91e2d81b3f1
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
62 atoms, 1 tree operation, 20 files

Browse the tree at this change

added crates/hub/migrations/0065_agent_tokens_muted_watches.sql +5 −0
1 +
-- Notifications (oxygenhub#183): a token an agent uses tells its owner
2 +
-- what was done with it; and owners watch their repositories by default,
3 +
-- so a watch row may now say it is muted.
4 +
ALTER TABLE tokens ADD COLUMN agent INTEGER NOT NULL DEFAULT 0;
5 +
ALTER TABLE watches ADD COLUMN muted INTEGER NOT NULL DEFAULT 0;
modified crates/hub/src/auth/mod.rs +16 −0
…
94 unchanged lines
95 95
    format!("{username}.{}", key.mac(format!("session:{username}:{session_salt}").as_bytes()))
96 96
}
97 97
98 +
/// Proof, for the code step of a sign-in with two-factor authentication,
99 +
/// that `username` gave the right password: `UNTIL.MAC`, good until `until`
100 +
/// (ms) and void as soon as the account's session salt changes (a new
101 +
/// password, or everyone signed out), so the code step need not ask for
102 +
/// the password again.
103 +
pub fn pending_login(key: &CookieKey, username: &str, session_salt: &str, until: i64) -> String {
104 +
    format!("{until}.{}", key.mac(format!("pending-login:{username}:{session_salt}:{until}").as_bytes()))
105 +
}
106 +
107 +
/// Whether `token` is a [`pending_login`] for this account still in time.
108 +
pub fn pending_login_ok(key: &CookieKey, username: &str, session_salt: &str, token: &str, now: i64) -> bool {
109 +
    let Some((until, _)) = token.split_once('.') else { return false };
110 +
    let Ok(until) = until.parse::<i64>() else { return false };
111 +
    until > now && mac_equal(token, &pending_login(key, username, session_salt, until))
112 +
}
113 +
98 114
/// The CSRF token bound to a session value.
99 115
pub fn csrf_token(key: &CookieKey, session_value: &str) -> String {
100 116
    key.mac(format!("csrf:{session_value}").as_bytes())
…
32 unchanged lines
modified crates/hub/src/backups.rs +94 −44
…
1328 unchanged lines
1329 1329
        .bind(id)
1330 1330
        .execute(&app.db)
1331 1331
        .await?;
1332 +
    let r: Run = sqlx::query_as("SELECT * FROM backup_runs WHERE id = ?").bind(id).fetch_one(&app.db).await?;
1332 1333
    if status != "ok" {
1333 1334
        notify_failure(app, &s, status, &detail).await;
1335 +
    } else {
1336 +
        notify_recovered(app, &s, &r).await;
1334 1337
    }
1335 -
    let r: Run = sqlx::query_as("SELECT * FROM backup_runs WHERE id = ?").bind(id).fetch_one(&app.db).await?;
1336 1338
    Ok(r)
1337 1339
}
1338 1340
…
157 unchanged lines
1496 1498
        }
1497 1499
    }
1498 1500
    Ok(())
1501 +
}
1502 +
1503 +
/// Who hears about `s`'s backups, and where they look: the user, or the
1504 +
/// organisation's owners.
1505 +
async fn told(app: &Shared, s: &Setting) -> Option<(Vec<(i64, db::notify::Reason)>, String)> {
1506 +
    let p = db::notify::Reason::Participating;
1507 +
    if s.kind == "org" {
1508 +
        let o = db::accounts::org_by_name(&app.db, &s.owner).await.ok().flatten()?;
1509 +
        let owners = db::accounts::org_members(&app.db, o.id)
1510 +
            .await
1511 +
            .ok()?
1512 +
            .into_iter()
1513 +
            .filter(|(_, r)| r == "owner")
1514 +
            .map(|(a, _)| (a.id, p))
1515 +
            .collect();
1516 +
        Some((owners, format!("/orgs/{}/settings/backups", s.owner)))
1517 +
    } else {
1518 +
        let a = db::accounts::account_by_name(&app.db, &s.owner).await.ok().flatten()?;
1519 +
        Some((vec![(a.id, p)], "/settings/backups".into()))
1520 +
    }
1521 +
}
1522 +
1523 +
/// The status of `owner`'s finished run before the newest.
1524 +
async fn status_before(app: &Shared, owner: &str) -> Option<String> {
1525 +
    sqlx::query_scalar("SELECT status FROM backup_runs WHERE owner = ? AND finished_ms IS NOT NULL ORDER BY id DESC LIMIT 1 OFFSET 1")
1526 +
        .bind(owner)
1527 +
        .fetch_optional(&app.db)
1528 +
        .await
1529 +
        .ok()
1530 +
        .flatten()
1499 1531
}
1500 1532
1533 +
/// A run that did not finish whole tells the owner (inbox and email), once
1534 +
/// per spell of failures: not when the run before failed too.
1501 1535
async fn notify_failure(app: &Shared, s: &Setting, status: &str, detail: &str) {
1502 -
    // Once per spell of failures: not when the run before failed too.
1503 -
    let before: Option<String> =
1504 -
        sqlx::query_scalar("SELECT status FROM backup_runs WHERE owner = ? AND finished_ms IS NOT NULL ORDER BY id DESC LIMIT 1 OFFSET 1")
1505 -
            .bind(&s.owner)
1506 -
            .fetch_optional(&app.db)
1507 -
            .await
1508 -
            .ok()
1509 -
            .flatten();
1510 -
    if before.as_deref().is_some_and(|b| b != "ok") {
1536 +
    if status_before(app, &s.owner).await.is_some_and(|b| b != "ok") {
1537 +
        return;
1538 +
    }
1539 +
    let Some((who, url)) = told(app, s).await else { return };
1540 +
    let title = format!("Backup of {} {status}: {}", s.owner, detail.chars().take(160).collect::<String>());
1541 +
    crate::notify::tell(app, "backup", &title, &url, "backups", None, who).await;
1542 +
}
1543 +
1544 +
/// A whole run after one that was not: the owner hears it works again
1545 +
/// (oxygenhub#183).
1546 +
async fn notify_recovered(app: &Shared, s: &Setting, run: &Run) {
1547 +
    if !status_before(app, &s.owner).await.is_some_and(|b| b != "ok") {
1511 1548
        return;
1549 +
    }
1550 +
    let Some((who, url)) = told(app, s).await else { return };
1551 +
    let title = format!("Backup of {} works again: {} repositories to {}", s.owner, run.repos, place(s));
1552 +
    crate::notify::tell(app, "backup.recovered", &title, &url, "backups", None, who).await;
1553 +
}
1554 +
1555 +
/// The hour (UTC) each day's backup summary goes out.
1556 +
const SUMMARY_HOUR: u8 = 7;
1557 +
const SUMMARY_SENT: &str = "backups.summary_day";
1558 +
1559 +
/// One message a day per owner (oxygenhub#183): the last day's runs, how
1560 +
/// they went and what they sent. Sent at the first scheduler tick from
1561 +
/// SUMMARY_HOUR (UTC), once a day. How many owners were told.
1562 +
pub async fn daily_summary(app: &Shared, now_ms: i64, force: bool) -> sqlx::Result<usize> {
1563 +
    let t = time::OffsetDateTime::from_unix_timestamp_nanos(now_ms as i128 * 1_000_000).unwrap_or(time::OffsetDateTime::UNIX_EPOCH);
1564 +
    let day = format!("{:04}-{:02}-{:02}", t.year(), u8::from(t.month()), t.day());
1565 +
    if !force && (t.hour() < SUMMARY_HOUR || db::accounts::setting(&app.db, SUMMARY_SENT).await?.as_deref() == Some(day.as_str())) {
1566 +
        return Ok(0);
1512 1567
    }
1513 -
    let (who, url): (Vec<i64>, String) = if s.kind == "org" {
1514 -
        match db::accounts::org_by_name(&app.db, &s.owner).await.ok().flatten() {
1515 -
            Some(o) => (
1516 -
                db::accounts::org_members(&app.db, o.id)
1517 -
                    .await
1518 -
                    .unwrap_or_default()
1519 -
                    .into_iter()
1520 -
                    .filter(|(_, r)| r == "owner")
1521 -
                    .map(|(a, _)| a.id)
1522 -
                    .collect(),
1523 -
                format!("/orgs/{}/settings#backups", s.owner),
1524 -
            ),
1525 -
            None => return,
1568 +
    db::accounts::set_setting(&app.db, SUMMARY_SENT, &day).await?;
1569 +
    let settings: Vec<Setting> = sqlx::query_as("SELECT * FROM backup_settings WHERE updated_ms > 0").fetch_all(&app.db).await?;
1570 +
    let mut sent = 0;
1571 +
    for s in settings {
1572 +
        let runs: Vec<Run> = sqlx::query_as("SELECT * FROM backup_runs WHERE owner = ? AND started_ms >= ? ORDER BY id")
1573 +
            .bind(&s.owner)
1574 +
            .bind(now_ms - 86_400_000)
1575 +
            .fetch_all(&app.db)
1576 +
            .await?;
1577 +
        if runs.is_empty() {
1578 +
            continue;
1526 1579
        }
1527 -
    } else {
1528 -
        match db::accounts::account_by_name(&app.db, &s.owner).await.ok().flatten() {
1529 -
            Some(a) => (vec![a.id], "/settings/backups".into()),
1530 -
            None => return,
1531 -
        }
1532 -
    };
1533 -
    let title = format!("Backup of {} {status}: {}", s.owner, detail.chars().take(160).collect::<String>());
1534 -
    for id in who {
1535 -
        let _ = db::notify::add(
1536 -
            &app.db,
1537 -
            db::notify::New {
1538 -
                account_id: id,
1539 -
                repo_id: None,
1540 -
                reason: db::notify::Reason::Participating,
1541 -
                kind: "backup",
1542 -
                title: &title,
1543 -
                url: &url,
1544 -
                actor: "",
1545 -
            },
1546 -
        )
1547 -
        .await;
1580 +
        let ok = runs.iter().filter(|r| r.status == "ok").count();
1581 +
        let bytes: i64 = runs.iter().map(|r| r.bytes).sum();
1582 +
        let repos = runs.iter().rev().find(|r| r.status == "ok").map_or(0, |r| r.repos);
1583 +
        let how = if ok == runs.len() { "all ok".to_string() } else { format!("{} not whole", runs.len() - ok) };
1584 +
        let title = format!(
1585 +
            "Backups of {} today: {} runs, {how}, {repos} repositories, {:.1} MiB sent to {}",
1586 +
            s.owner,
1587 +
            runs.len(),
1588 +
            bytes as f64 / 1_048_576.0,
1589 +
            place(&s)
1590 +
        );
1591 +
        let Some((who, url)) = told(app, &s).await else { continue };
1592 +
        crate::notify::tell(app, "backup.summary", &title, &url, "backups", None, who).await;
1593 +
        sent += 1;
1548 1594
    }
1595 +
    Ok(sent)
1549 1596
}
1550 1597
1551 1598
/// Run each owner's backup when it is due, one at a time. A run left
…
7 unchanged lines
1559 1606
    .await;
1560 1607
    loop {
1561 1608
        tokio::time::sleep(std::time::Duration::from_secs(60)).await;
1609 +
        if let Err(e) = daily_summary(&app, db::now_ms(), false).await {
1610 +
            tracing::warn!("backup summary: {e}");
1611 +
        }
1562 1612
        for s in due(&app.db).await.unwrap_or_default() {
1563 1613
            if let Err(e) = run(&app, &s.owner, "schedule").await {
1564 1614
                tracing::warn!("backup of {}: {e:#}", s.owner);
…
192 unchanged lines
modified crates/hub/src/db/accounts.rs +15 −0
…
93 unchanged lines
94 94
    pub last_used_ms: Option<i64>,
95 95
    /// A renewing token's lifetime: each use extends it to now plus this.
96 96
    pub renew_ms: Option<i64>,
97 +
    /// An agent uses it: what is done with it is told to its owner
98 +
    /// (migration 0065, oxygenhub#183).
99 +
    #[sqlx(default)]
100 +
    pub agent: bool,
97 101
}
98 102
99 103
impl Token {
…
415 unchanged lines
515 519
516 520
pub async fn tokens_of(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<Token>> {
517 521
    sqlx::query_as("SELECT * FROM tokens WHERE account_id = ? ORDER BY created_ms DESC").bind(account_id).fetch_all(pool).await
522 +
}
523 +
524 +
/// Mark one of `account_id`'s tokens as an agent's, or not.
525 +
pub async fn set_token_agent(pool: &Pool, account_id: i64, id: i64, agent: bool) -> sqlx::Result<bool> {
526 +
    let r = sqlx::query("UPDATE tokens SET agent = ? WHERE id = ? AND account_id = ?")
527 +
        .bind(agent)
528 +
        .bind(id)
529 +
        .bind(account_id)
530 +
        .execute(pool)
531 +
        .await?;
532 +
    Ok(r.rows_affected() > 0)
518 533
}
519 534
520 535
pub async fn token_by_hash(pool: &Pool, hash: &str) -> sqlx::Result<Option<Token>> {
…
410 unchanged lines
modified crates/hub/src/db/notify.rs +42 −11
…
201 unchanged lines
202 202
203 203
// ---------------------------------------------------------------- watching
204 204
205 +
/// The accounts that own repository `repo_id`: its owner, or the owners of
206 +
/// the organisation that owns it. They watch it unless they mute it
207 +
/// (oxygenhub#183).
208 +
pub async fn owners_of(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<i64>> {
209 +
    let owner: Option<String> =
210 +
        sqlx::query_scalar("SELECT owner FROM repositories WHERE id = ?").bind(repo_id).fetch_optional(pool).await?;
211 +
    let Some(owner) = owner else { return Ok(Vec::new()) };
212 +
    if let Some(a) = super::accounts::account_by_name(pool, &owner).await? {
213 +
        return Ok(vec![a.id]);
214 +
    }
215 +
    match super::accounts::org_by_name(pool, &owner).await? {
216 +
        Some(o) => {
217 +
            Ok(super::accounts::org_members(pool, o.id).await?.into_iter().filter(|(_, r)| r == "owner").map(|(a, _)| a.id).collect())
218 +
        }
219 +
        None => Ok(Vec::new()),
220 +
    }
221 +
}
222 +
223 +
/// Whether `account_id` watches `repo_id`: a watch it chose, or, for an
224 +
/// owner, unless it muted it.
205 225
pub async fn watching(pool: &Pool, account_id: i64, repo_id: i64) -> sqlx::Result<bool> {
206 -
    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM watches WHERE account_id = ? AND repo_id = ?")
226 +
    let muted: Option<bool> = sqlx::query_scalar("SELECT muted FROM watches WHERE account_id = ? AND repo_id = ?")
207 227
        .bind(account_id)
208 228
        .bind(repo_id)
209 -
        .fetch_one(pool)
229 +
        .fetch_optional(pool)
210 230
        .await?;
211 -
    Ok(n > 0)
231 +
    match muted {
232 +
        Some(m) => Ok(!m),
233 +
        None => Ok(owners_of(pool, repo_id).await?.contains(&account_id)),
234 +
    }
212 235
}
213 236
237 +
/// Watch `repo_id`, or stop: for an owner, stopping mutes it.
214 238
pub async fn set_watch(pool: &Pool, account_id: i64, repo_id: i64, on: bool) -> sqlx::Result<()> {
215 -
    if on {
216 -
        sqlx::query("INSERT OR IGNORE INTO watches (account_id, repo_id) VALUES (?, ?)")
217 -
            .bind(account_id)
218 -
            .bind(repo_id)
239 +
    sqlx::query("INSERT INTO watches (account_id, repo_id, muted) VALUES (?, ?, ?) ON CONFLICT (account_id, repo_id) DO UPDATE SET muted = excluded.muted")
240 +
        .bind(account_id)
241 +
        .bind(repo_id)
242 +
        .bind(!on)
219 243
        .execute(pool)
220 244
        .await?;
221 -
    } else {
222 -
        sqlx::query("DELETE FROM watches WHERE account_id = ? AND repo_id = ?").bind(account_id).bind(repo_id).execute(pool).await?;
223 -
    }
224 245
    Ok(())
225 246
}
226 247
248 +
/// Everyone watching `repo_id`: those who chose to, and its owners who
249 +
/// have not muted it.
227 250
pub async fn watchers(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<i64>> {
228 -
    sqlx::query_scalar("SELECT account_id FROM watches WHERE repo_id = ?").bind(repo_id).fetch_all(pool).await
251 +
    let rows: Vec<(i64, bool)> =
252 +
        sqlx::query_as("SELECT account_id, muted FROM watches WHERE repo_id = ?").bind(repo_id).fetch_all(pool).await?;
253 +
    let mut out: Vec<i64> = rows.iter().filter(|(_, m)| !m).map(|(a, _)| *a).collect();
254 +
    for o in owners_of(pool, repo_id).await? {
255 +
        if !rows.iter().any(|(a, _)| *a == o) && !out.contains(&o) {
256 +
            out.push(o);
257 +
        }
258 +
    }
259 +
    Ok(out)
229 260
}
230 261
231 262
pub async fn subscribe(pool: &Pool, account_id: i64, kind: &str, target_id: i64) -> sqlx::Result<()> {
…
75 unchanged lines
modified crates/hub/src/db/repos.rs +1 −1
…
282 unchanged lines
283 283
/// Stars and watchers of one repository.
284 284
pub async fn counts(pool: &Pool, repo_id: i64) -> sqlx::Result<(i64, i64)> {
285 285
    let stars: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM stars WHERE repo_id = ?").bind(repo_id).fetch_one(pool).await?;
286 -
    let watchers: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM watches WHERE repo_id = ?").bind(repo_id).fetch_one(pool).await?;
286 +
    let watchers = super::notify::watchers(pool, repo_id).await?.len() as i64;
287 287
    Ok((stars, watchers))
288 288
}
289 289
…
62 unchanged lines
modified crates/hub/src/diffusion.rs +15 −0
…
760 unchanged lines
761 761
            .ok()?;
762 762
            pdb::add_event(&app.db, p.id, ACCOUNT, "opened", "").await.ok();
763 763
            db::notify::index(&app.db, "proposal", consumer.id, p.id, p.number, &p.title, &p.body).await.ok();
764 +
            // Told as a proposal opened on the web is (oxygenhub#183).
765 +
            crate::notify::deliver(
766 +
                app,
767 +
                crate::notify::Event {
768 +
                    kind: "proposal.opened",
769 +
                    title: format!("{}/{}#{}: {}", consumer.owner, consumer.name, p.number, p.title),
770 +
                    url: format!("/{}/{}/proposal/{}", consumer.owner, consumer.name, p.number),
771 +
                    actor: &me,
772 +
                    repo_id: consumer.id,
773 +
                    thread: Some(("proposal", p.id)),
774 +
                    mention_text: "",
775 +
                    direct: vec![],
776 +
                },
777 +
            )
778 +
            .await;
764 779
            if let Ok(label) =
765 780
                db::issues::upsert_label(&app.db, consumer.id, "dependencies", "#0072ff", "A subrepo moves (Diffusion)").await
766 781
            {
…
541 unchanged lines
modified crates/hub/src/flow.rs +43 −0
…
1624 unchanged lines
1625 1625
        if done.status == "failed" && !done.live() {
1626 1626
            tell_failed(app, &row, &done).await;
1627 1627
        }
1628 +
        // A pass after a failure on the same bookmark: fixed (#183).
1629 +
        if done.status == "passed" {
1630 +
            let before: Option<String> = sqlx::query_scalar(
1631 +
                "SELECT status FROM flow_runs WHERE repo_id = ? AND bookmark = ? AND id < ? AND status IN ('passed', 'failed')
1632 +
                 ORDER BY id DESC LIMIT 1",
1633 +
            )
1634 +
            .bind(done.repo_id)
1635 +
            .bind(&done.bookmark)
1636 +
            .bind(done.id)
1637 +
            .fetch_optional(&app.db)
1638 +
            .await?;
1639 +
            // Or this very run failed and a job of it was run again.
1640 +
            let url = format!("/{}/{}/flow/{}", row.owner, row.name, done.number);
1641 +
            let failed_here: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM notifications WHERE kind = 'flow.failed' AND url = ?")
1642 +
                .bind(&url)
1643 +
                .fetch_one(&app.db)
1644 +
                .await?;
1645 +
            if before.as_deref() == Some("failed") || failed_here > 0 {
1646 +
                tell_fixed(app, &row, &done).await;
1647 +
            }
1648 +
        }
1628 1649
    }
1629 1650
    // A passing run of an upstream's default bookmark: consumers following
1630 1651
    // it when green are bumped (Diffusion, #148).
…
41 unchanged lines
1672 1693
        crate::notify::Event {
1673 1694
            kind: "flow.failed",
1674 1695
            title: format!("{}/{}: run #{}{on} failed ({})", row.owner, row.name, run.number, broken.join(", ")),
1696 +
            url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number),
1697 +
            actor: &me,
1698 +
            repo_id: row.id,
1699 +
            thread: None,
1700 +
            mention_text: "",
1701 +
            direct: pusher.into_iter().map(|a| a.id).collect(),
1702 +
        },
1703 +
    )
1704 +
    .await;
1705 +
}
1706 +
1707 +
/// A run that passes after its bookmark's last one failed (oxygenhub#183):
1708 +
/// its pusher and the repository's watchers hear it is green again.
1709 +
async fn tell_fixed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run) {
1710 +
    let Some(me) = flow_account(&app.db).await else { return };
1711 +
    let pusher = db::accounts::account_by_name(&app.db, &run.actor).await.ok().flatten();
1712 +
    let on = if run.bookmark.is_empty() { String::new() } else { format!(" on {}", run.bookmark) };
1713 +
    crate::notify::deliver(
1714 +
        app,
1715 +
        crate::notify::Event {
1716 +
            kind: "flow.fixed",
1717 +
            title: format!("{}/{}: run #{}{on} passed: fixed", row.owner, row.name, run.number),
1675 1718
            url: format!("/{}/{}/flow/{}", row.owner, row.name, run.number),
1676 1719
            actor: &me,
1677 1720
            repo_id: row.id,
…
408 unchanged lines
modified crates/hub/src/lib.rs +25 −1
…
157 unchanged lines
158 158
159 159
/// The full router: pages, API, protocol and static assets.
160 160
pub fn router(app: Shared) -> axum::Router {
161 -
    web::router(app).layer(axum::middleware::from_fn(private_pages)).layer(axum::middleware::from_fn(slow_requests))
161 +
    web::router(app.clone())
162 +
        .layer(axum::middleware::from_fn_with_state(app, agent_requests))
163 +
        .layer(axum::middleware::from_fn(private_pages))
164 +
        .layer(axum::middleware::from_fn(slow_requests))
162 165
}
163 166
167 +
/// A request made with a token its owner marked as an agent's runs with
168 +
/// that token's name in [`notify::AGENT`], so what the agent does is told
169 +
/// to its owner (oxygenhub#183).
170 +
async fn agent_requests(
171 +
    axum::extract::State(app): axum::extract::State<Shared>,
172 +
    req: axum::extract::Request,
173 +
    next: axum::middleware::Next,
174 +
) -> axum::response::Response {
175 +
    let token = req
176 +
        .headers()
177 +
        .get(axum::http::header::AUTHORIZATION)
178 +
        .and_then(|v| v.to_str().ok())
179 +
        .and_then(|v| v.strip_prefix("Bearer "))
180 +
        .map(|t| auth::token_hash(t.trim()));
181 +
    let agent = match token {
182 +
        Some(hash) => db::accounts::token_by_hash(&app.db, &hash).await.ok().flatten().filter(|t| t.agent).map(|t| t.name),
183 +
        None => None,
184 +
    };
185 +
    notify::AGENT.scope(agent, next.run(req)).await
186 +
}
187 +
164 188
/// Requests slower than this are logged, with their path (#145).
165 189
const SLOW: std::time::Duration = std::time::Duration::from_secs(2);
166 190
…
70 unchanged lines
modified crates/hub/src/notify/mod.rs +31 −9
…
4 unchanged lines
5 5
use crate::Shared;
6 6
use std::collections::BTreeSet;
7 7
8 +
tokio::task_local! {
9 +
    /// The name of the agent-marked token this request came with, if any
10 +
    /// (oxygenhub#183): what an agent does with it is told to the token's
11 +
    /// owner too, though the owner is the actor.
12 +
    pub static AGENT: Option<String>;
13 +
}
14 +
15 +
/// The agent token acting now, when there is one.
16 +
pub fn agent_acting() -> Option<String> {
17 +
    AGENT.try_with(|a| a.clone()).ok().flatten()
18 +
}
19 +
8 20
/// An event worth telling people about.
9 21
pub struct Event<'a> {
10 22
    pub kind: &'a str,
…
43 unchanged lines
54 66
    if let Ok(w) = ndb::watchers(pool, ev.repo_id).await {
55 67
        targets.extend(w.into_iter().filter(|id| !participants.contains(id)).map(|id| (id, ndb::Reason::Watching)));
56 68
    }
57 -
    targets.retain(|(id, _)| *id != ev.actor.id);
58 -
    let repo_id = (ev.repo_id > 0).then_some(ev.repo_id);
69 +
    // Never the actor, unless an agent acted with their token: then they
70 +
    // hear what their agent did.
71 +
    let agent = agent_acting();
72 +
    if agent.is_none() {
73 +
        targets.retain(|(id, _)| *id != ev.actor.id);
74 +
    }
75 +
    let actor = match &agent {
76 +
        Some(token) => format!("{} (agent token {token})", ev.actor.username),
77 +
        None => ev.actor.username.clone(),
78 +
    };
79 +
    tell(app, ev.kind, &ev.title, &ev.url, &actor, (ev.repo_id > 0).then_some(ev.repo_id), targets).await;
80 +
}
81 +
82 +
/// Tell each of `targets` about something: an inbox row, and an email
83 +
/// where the account asked for them and has proven its address.
84 +
pub async fn tell(app: &Shared, kind: &str, title: &str, url: &str, actor: &str, repo_id: Option<i64>, targets: Vec<(i64, ndb::Reason)>) {
85 +
    let pool = &app.db;
59 86
    for (id, reason) in targets {
60 -
        ndb::add(
61 -
            pool,
62 -
            ndb::New { account_id: id, repo_id, reason, kind: ev.kind, title: &ev.title, url: &ev.url, actor: &ev.actor.username },
63 -
        )
64 -
        .await
65 -
        .ok();
87 +
        ndb::add(pool, ndb::New { account_id: id, repo_id, reason, kind, title, url, actor }).await.ok();
66 88
        if let Ok(Some(a)) = db::accounts::account_by_id(pool, id).await {
67 89
            // Only to addresses the account has proven it reads (hub spec §5.1.1).
68 90
            if a.email_notifications && a.email_verified && !a.email.is_empty() && crate::mail::enabled(app) {
69 -
                email(app, &a, &ev.title, &ev.url, &ev.actor.username).await;
91 +
                email(app, &a, title, url, actor).await;
70 92
            }
71 93
        }
72 94
    }
…
20 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
107 unchanged lines
108 108
        .route("/orgs/{name}/settings/backups/run", post(backups::org_run))
109 109
        .route("/orgs/{name}/settings/backups/test", post(backups::org_test))
110 110
        .route("/settings/tokens/{id}/delete", post(settings::tokens_delete))
111 +
        .route("/settings/tokens/{id}/agent", post(settings::tokens_agent))
111 112
        .route("/new", get(repo_settings::new_form).post(repo_settings::create))
112 113
        .route("/orgs/new", get(orgs::new_form).post(orgs::create))
113 114
        .route("/orgs/{name}/settings", get(orgs::settings).post(orgs::update))
…
654 unchanged lines
modified crates/hub/src/web/session.rs +48 −5
…
17 unchanged lines
18 18
    next: String,
19 19
    /// Second step: the password was right, a one-time code is needed.
20 20
    totp_for: Option<String>,
21 +
    /// That step's proof of the password (`auth::pending_login`), so it
22 +
    /// asks only for the code.
23 +
    pending: String,
21 24
    /// Arrived from a password reset.
22 25
    reset: bool,
23 26
    /// Providers to sign in with (#106): (id, name).
…
26 unchanged lines
50 53
        error: q.error,
51 54
        next: safe_next(q.next),
52 55
        totp_for: None,
56 +
        pending: String::new(),
53 57
        reset,
54 58
        providers,
55 59
    })
…
3 unchanged lines
59 63
#[derive(Deserialize)]
60 64
pub struct LoginForm {
61 65
    username: String,
66 +
    #[serde(default)]
62 67
    password: String,
68 +
    /// The code step's proof of the password (`auth::pending_login`).
69 +
    #[serde(default)]
70 +
    pending: String,
63 71
    #[serde(default)]
64 72
    code: String,
65 73
    #[serde(default)]
…
3 unchanged lines
69 77
pub async fn login(State(app): State<Shared>, viewer: Viewer, Form(f): Form<LoginForm>) -> Result<Response, Error> {
70 78
    let shell = Shell::new(&app, "/login", &viewer);
71 79
    let next = safe_next(Some(f.next.clone()));
72 -
    let fail = |msg: &str, totp_for: Option<String>| {
73 -
        Page(LoginPage { shell: shell.clone(), error: Some(msg.into()), next: next.clone(), totp_for, reset: false, providers: vec![] })
74 -
            .into_response()
75 -
    };
76 80
    let username = f.username.trim().to_string();
77 81
    let account = accounts::account_by_name(&app.db, &username).await.map_err(|e| Error::from_db(shell.clone(), e))?;
78 -
    let Some(account) = account.filter(|a| auth::verify_password(&a.password_hash, &f.password)) else {
82 +
    // The code step carries proof of the password, good for a few minutes;
83 +
    // a fresh one goes with each retry of the code.
84 +
    let now_ms = db::now_ms();
85 +
    let proven = !f.pending.is_empty()
86 +
        && account.as_ref().is_some_and(|a| auth::pending_login_ok(&app.cookie_key, &a.username, &a.session_salt, &f.pending, now_ms));
87 +
    let pending = account
88 +
        .as_ref()
89 +
        .map(|a| auth::pending_login(&app.cookie_key, &a.username, &a.session_salt, now_ms + PENDING_LOGIN_MS))
90 +
        .unwrap_or_default();
91 +
    let fail = |msg: &str, totp_for: Option<String>| {
92 +
        Page(LoginPage {
93 +
            shell: shell.clone(),
94 +
            error: Some(msg.into()),
95 +
            next: next.clone(),
96 +
            totp_for,
97 +
            pending: pending.clone(),
98 +
            reset: false,
99 +
            providers: vec![],
100 +
        })
101 +
        .into_response()
102 +
    };
103 +
    if !f.pending.is_empty() && !proven {
104 +
        return Ok(fail("That took too long: sign in again.", None));
105 +
    }
106 +
    let Some(account) = account.filter(|a| proven || auth::verify_password(&a.password_hash, &f.password)) else {
79 107
        accounts::audit(&app.db, None, "login.failed", &username, "").await.ok();
80 108
        return Ok(fail("Wrong username or password.", None));
81 109
    };
…
24 unchanged lines
106 134
    accounts::audit(&app.db, Some(&account), "login", &username, "").await.ok();
107 135
    Ok(signed_in(&app, &account, &next))
108 136
}
137 +
138 +
/// How long the code step of a sign-in may take after the password.
139 +
const PENDING_LOGIN_MS: i64 = 5 * 60 * 1000;
109 140
110 141
/// A redirect that sets the session cookie.
111 142
pub fn signed_in(app: &Shared, account: &accounts::Account, to: &str) -> Response {
…
103 unchanged lines
215 246
    accounts::audit(&app.db, Some(&account), "register", &username, if first { "first account: administrator" } else { "" }).await.ok();
216 247
    if let Err(e) = super::recovery::send_verification(&app, &account).await {
217 248
        tracing::info!("verification for {username}: {e}");
249 +
    }
250 +
    // The site's administrators hear of each new account (oxygenhub#183).
251 +
    if !first {
252 +
        let admins: Vec<(i64, crate::db::notify::Reason)> = accounts::accounts(&app.db)
253 +
            .await
254 +
            .unwrap_or_default()
255 +
            .into_iter()
256 +
            .filter(|a| a.is_admin)
257 +
            .map(|a| (a.id, crate::db::notify::Reason::Participating))
258 +
            .collect();
259 +
        let title = format!("New account: {username} signed up on {}", app.config.site.name);
260 +
        crate::notify::tell(&app, "account.registered", &title, &format!("/{username}"), &username, None, admins).await;
218 261
    }
219 262
    Ok(signed_in(&app, &account, "/"))
220 263
}
modified crates/hub/src/web/settings.rs +33 −0
…
611 unchanged lines
612 612
    repos: String,
613 613
    #[serde(default)]
614 614
    expires_days: String,
615 +
    /// An agent uses it: tell me what it does (oxygenhub#183).
616 +
    #[serde(default)]
617 +
    agent: String,
615 618
}
616 619
617 620
/// Every scope the hub checks, with what it grants. The tokens page lists
…
31 unchanged lines
649 652
    accounts::create_token(&app.db, me.account.id, name, &hash, &scopes.join(","), &repos, expires_ms)
650 653
        .await
651 654
        .map_err(|e| Error::from_db(shell.clone(), e))?;
655 +
    if !f.agent.is_empty() {
656 +
        if let Ok(Some(t)) = accounts::token_by_hash(&app.db, &hash).await {
657 +
            accounts::set_token_agent(&app.db, me.account.id, t.id, true).await.map_err(|e| Error::from_db(shell.clone(), e))?;
658 +
        }
659 +
    }
652 660
    accounts::audit(&app.db, Some(&me.account), "token.created", name, &scopes.join(",")).await.ok();
653 661
    tokens_page(&app, shell, me.account, Some(secret), Some("Copy the token now; it is not shown again.".into()), None).await
662 +
}
663 +
664 +
/// Mark a token as an agent's, or not (oxygenhub#183): what is done with
665 +
/// an agent's token is told to its owner, as anyone else's doing is.
666 +
pub async fn tokens_agent(
667 +
    State(app): State<Shared>,
668 +
    viewer: Viewer,
669 +
    Path(id): Path<i64>,
670 +
    Form(f): Form<super::session::CsrfForm>,
671 +
) -> Result<Page<TokensPage>, Error> {
672 +
    let shell = Shell::new(&app, "/settings/tokens", &viewer);
673 +
    let me = require(&viewer, &shell)?;
674 +
    csrf(&viewer, &f.csrf, &shell)?;
675 +
    let tokens = accounts::tokens_of(&app.db, me.account.id).await.map_err(|e| Error::from_db(shell.clone(), e))?;
676 +
    let Some(t) = tokens.iter().find(|t| t.id == id) else {
677 +
        return tokens_page(&app, shell, me.account, None, None, Some("No such token.".into())).await;
678 +
    };
679 +
    accounts::set_token_agent(&app.db, me.account.id, id, !t.agent).await.map_err(|e| Error::from_db(shell.clone(), e))?;
680 +
    accounts::audit(&app.db, Some(&me.account), "token.agent", &t.name, if t.agent { "off" } else { "on" }).await.ok();
681 +
    let notice = if t.agent {
682 +
        format!("{} is no longer marked as an agent's.", t.name)
683 +
    } else {
684 +
        format!("{} is an agent's: you will hear what it does.", t.name)
685 +
    };
686 +
    tokens_page(&app, shell, me.account, None, Some(notice), None).await
654 687
}
655 688
656 689
pub async fn tokens_delete(
…
12 unchanged lines
modified crates/hub/tests/accounts.rs +20 −10
…
301 unchanged lines
302 302
    unique.sort();
303 303
    unique.dedup();
304 304
    assert_eq!(unique.len(), names.len(), "a field named twice: {names:?}");
305 +
    // The code step asks for the code alone: the password is not typed
306 +
    // again, the step carries proof of it instead.
307 +
    assert!(!names.contains(&"password"), "{names:?}");
308 +
    let pending = form.split("name=\"pending\" value=\"").nth(1).unwrap().split('"').next().unwrap().to_string();
305 309
    let code = oxygenhub::auth::totp::current(&secret, (db::now_ms() / 1000) as u64).unwrap();
306 -
    let body: Vec<String> = names
307 -
        .iter()
308 -
        .map(|n| match *n {
309 -
            "username" => "username=andy".to_string(),
310 -
            "password" => "password=correct-horse-battery".to_string(),
311 -
            "code" => format!("code={code}"),
312 -
            other => format!("{other}="),
313 -
        })
314 -
        .collect();
315 -
    let r = post(app, "/login", None, &body.join("&")).await;
310 +
    let fill = |pending: &str| -> String {
311 +
        names
312 +
            .iter()
313 +
            .map(|n| match *n {
314 +
                "username" => "username=andy".to_string(),
315 +
                "pending" => format!("pending={pending}"),
316 +
                "code" => format!("code={code}"),
317 +
                other => format!("{other}="),
318 +
            })
319 +
            .collect::<Vec<_>>()
320 +
            .join("&")
321 +
    };
322 +
    // A forged or stale proof starts again.
323 +
    let r = post(app, "/login", None, &fill("1.forged")).await;
324 +
    assert!(r.body.contains("sign in again") && r.cookie.is_none(), "{}", r.body);
325 +
    let r = post(app, "/login", None, &fill(&pending)).await;
316 326
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
317 327
    assert!(r.cookie.is_some());
318 328
    assert!(r.location.as_deref() == Some("/"));
…
764 unchanged lines
modified crates/hub/tests/backups.rs +14 −0
…
854 unchanged lines
855 855
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
856 856
    }
857 857
    assert_eq!((done["status"].as_str(), done["repos"].as_i64(), done["trigger"].as_str()), (Some("ok"), Some(1), Some("now")), "{done}");
858 +
    // A whole run after a failed one says it works again; the day's
859 +
    // summary tells the owner what ran (#183).
860 +
    sqlx::query("INSERT INTO backup_runs (owner, started_ms, finished_ms, status, at, trigger, detail) VALUES ('andy', 1, 2, 'failed', 'x', 'schedule', 'GCS refused')")
861 +
        .execute(&h.app.db)
862 +
        .await
863 +
        .unwrap();
864 +
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
865 +
    let again = backups::run(&h.app, "andy", "now").await.unwrap();
866 +
    assert_eq!(again.status, "ok");
867 +
    let told = |db: sqlx::SqlitePool| async move { db::notify::inbox(&db, andy.id, 20).await.unwrap() };
868 +
    assert!(told(h.app.db.clone()).await.iter().any(|n| n.kind == "backup.recovered" && n.title.contains("works again")));
869 +
    assert_eq!(backups::daily_summary(&h.app, db::now_ms(), true).await.unwrap(), 1);
870 +
    let summary = told(h.app.db.clone()).await.into_iter().find(|n| n.kind == "backup.summary").expect("a summary");
871 +
    assert!(summary.title.contains("Backups of andy today") && summary.title.contains("runs"), "{}", summary.title);
858 872
859 873
    // An organisation's, by its owner; another account sees no such thing.
860 874
    let (st, _) =
…
16 unchanged lines
modified crates/hub/tests/flow.rs +4 −0
…
3979 unchanged lines
3980 3980
        StatusCode::NO_CONTENT
3981 3981
    );
3982 3982
    assert_eq!(db::flow::run_by_id(&h.app.db, run.id).await.unwrap().unwrap().status, "passed", "the run is whole again");
3983 +
    // Andy owns it: told it failed, then that it is fixed (#183).
3984 +
    let andy_id = db::accounts::account_by_name(&h.app.db, "andy").await.unwrap().unwrap().id;
3985 +
    let kinds: Vec<String> = db::notify::inbox(&h.app.db, andy_id, 20).await.unwrap().into_iter().map(|n| n.kind).collect();
3986 +
    assert!(kinds.contains(&"flow.failed".to_string()) && kinds.contains(&"flow.fixed".to_string()), "{kinds:?}");
3983 3987
3984 3988
    // The build again brings the test that depends on it; lint stays.
3985 3989
    let (_, body) =
…
88 unchanged lines
modified crates/hub/tests/issues.rs +75 −0
…
1061 unchanged lines
1062 1062
    let bad = post(app, "/bob/game/settings/backup", Some(&bob), &format!("_csrf={csrf}&backup=weekly")).await;
1063 1063
    assert!(bad.body.contains("Pick how this repository is backed up."));
1064 1064
}
1065 +
1066 +
/// oxygenhub#183: an owner hears about their repository by default, until
1067 +
/// they mute it; what an agent does with a token its owner marked is told
1068 +
/// to that owner; the administrators hear of each new account.
1069 +
#[tokio::test(flavor = "multi_thread")]
1070 +
async fn owners_hear_by_default_and_agents_tell_their_owner() {
1071 +
    let h = hub().await;
1072 +
    let app = &h.app;
1073 +
    let andy = register(app, "andy").await;
1074 +
    let bob = register(app, "bob").await;
1075 +
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
1076 +
    let kinds = |id: i64| {
1077 +
        let db = app.db.clone();
1078 +
        async move { db::notify::inbox(&db, id, 50).await.unwrap().into_iter().map(|n| (n.kind, n.title, n.actor)).collect::<Vec<_>>() }
1079 +
    };
1080 +
    assert!(kinds(andy_id).await.iter().any(|(k, t, _)| k == "account.registered" && t.contains("bob")), "the administrator hears of bob");
1081 +
    let repo = RepoName::parse("andy/demo").unwrap();
1082 +
    app.repos.create(&repo, None).unwrap();
1083 +
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
1084 +
    let open = |who: &'static str, title: &'static str| {
1085 +
        let (app, cookie) = (app.clone(), if who == "bob" { bob.clone() } else { andy.clone() });
1086 +
        async move {
1087 +
            let csrf = csrf_of(&get(&app, "/andy/demo/issues/new", Some(&cookie)).await.body);
1088 +
            post(&app, "/andy/demo/issues/new", Some(&cookie), &format!("_csrf={csrf}&title={title}&body=x")).await
1089 +
        }
1090 +
    };
1091 +
    // Andy never pressed Watch, but owns it: he hears of bob's issue.
1092 +
    assert!(get(app, "/andy/demo", Some(&andy)).await.body.contains("UNWATCH"), "an owner watches by default");
1093 +
    open("bob", "First").await;
1094 +
    assert!(kinds(andy_id).await.iter().any(|(k, t, _)| k == "issue.opened" && t.contains("First")));
1095 +
    // Muted, he does not.
1096 +
    let csrf = csrf_of(&get(app, "/andy/demo", Some(&andy)).await.body);
1097 +
    post(app, "/andy/demo/watch", Some(&andy), &format!("_csrf={csrf}")).await;
1098 +
    assert!(get(app, "/andy/demo", Some(&andy)).await.body.contains(">WATCH<"), "muted");
1099 +
    open("bob", "Second").await;
1100 +
    assert!(!kinds(andy_id).await.iter().any(|(_, t, _)| t.contains("Second")));
1101 +
    post(app, "/andy/demo/watch", Some(&andy), &format!("_csrf={csrf}")).await;
1102 +
1103 +
    // His own doing is not told to him; his agent's is.
1104 +
    let token = |agent: bool| {
1105 +
        let db = app.db.clone();
1106 +
        async move {
1107 +
            let (t, hash) = oxygenhub::auth::new_token();
1108 +
            db::accounts::create_token(&db, andy_id, if agent { "Claude" } else { "laptop" }, &hash, "repo:read,repo:write", "", None)
1109 +
                .await
1110 +
                .unwrap();
1111 +
            if agent {
1112 +
                let id = db::accounts::token_by_hash(&db, &hash).await.unwrap().unwrap().id;
1113 +
                db::accounts::set_token_agent(&db, andy_id, id, true).await.unwrap();
1114 +
            }
1115 +
            t
1116 +
        }
1117 +
    };
1118 +
    let api_issue = |t: String, title: &'static str| {
1119 +
        let app = app.clone();
1120 +
        async move {
1121 +
            use tower::ServiceExt;
1122 +
            let req = axum::http::Request::post("/api/v1/repos/andy/demo/issues")
1123 +
                .header("authorization", format!("Bearer {t}"))
1124 +
                .header("content-type", "application/json")
1125 +
                .body(axum::body::Body::from(serde_json::json!({ "title": title, "body": "" }).to_string()))
1126 +
                .unwrap();
1127 +
            oxygenhub::router(app).oneshot(req).await.unwrap().status()
1128 +
        }
1129 +
    };
1130 +
    assert_eq!(api_issue(token(false).await, "By hand").await, StatusCode::CREATED);
1131 +
    assert!(!kinds(andy_id).await.iter().any(|(_, t, _)| t.contains("By hand")), "his own doing");
1132 +
    assert_eq!(api_issue(token(true).await, "By the agent").await, StatusCode::CREATED);
1133 +
    let told = kinds(andy_id).await;
1134 +
    let agent = told.iter().find(|(_, t, _)| t.contains("By the agent")).expect("told of his agent's issue");
1135 +
    assert_eq!(agent.2, "andy (agent token Claude)");
1136 +
    // The tokens page marks and unmarks one.
1137 +
    let page = get(app, "/settings/tokens", Some(&andy)).await;
1138 +
    assert!(page.body.contains("agent ✓") && page.body.contains("mark as agent"), "{}", page.body);
1139 +
}
modified docs/spec.md +2 −0
…
451 unchanged lines
452 452
453 453
The inbox shows one row per **thread** (the page a notification is about, such as an issue or a proposal), with its newest event, how many updates it had and how many are new. Each notification records why it was sent: **participating** (the account opened, commented on, was assigned, mentioned in or asked to review the thread) or **watching** (it only watches the repository); a thread counts as participating if any of its notifications does. The inbox filters by view (inbox, unread, done), by reason, by kind (`issue`, `proposal`, `release`, …) and by repository, with counts over the whole inbox. A thread can be marked read or unread, and **done**, which sets it aside (read) until something new happens in it. Opening a row marks its thread read and goes to the page.
454 454
455 +
**Who hears, and of what** (oxygenhub#183, 2026-10-14). A repository's owners (the user, or an organisation's owners) watch it by default and stop by unwatching it, which mutes it (`watches.muted`, migration 0065); others watch by choice. A proposal Diffusion opens is told as one opened on the web is. The actor is never told of their own doing, except through a token they marked as an agent's (Settings › Tokens, "an agent uses this token"): a request with it runs with the token's name in `notify::AGENT`, and its events reach the owner like anyone else's, the actor shown as `andymccall (agent token Claude)`. OxygenFlow tells a run's pusher and the watchers when it fails (`flow.failed`) and when a bookmark is green again (`flow.fixed`: the run before on it failed, or this run had failed and a job of it was run again). Backups tell the owner when a run is not whole (once per spell), when one is whole again (`backup.recovered`), and once a day from 07:00 UTC with the day's runs, outcome, repositories and bytes sent (`backup.summary`). The site's administrators hear of each new account (`account.registered`). Each goes to the inbox, and by email to accounts that turned email on and verified their address.
456 +
455 457
## 11. Search
456 458
457 459
Global search box with scopes: repositories (name, description, topics), issues and proposals (FTS5 over title, body and comments), changes (message FTS5 over the default bookmark's state, indexed on push), accounts, and code. Code search in 1.0 is a per-repository substring and regular-expression search over the default state's text files, run with a time budget and a result cap, cached per state hash. A trigram index is a 1.x item.
…
300 unchanged lines
modified templates/login.html +2 −2
…
7 unchanged lines
8 8
  <input type="hidden" name="next" value="{{ next }}">
9 9
  {% if let Some(u) = totp_for %}
10 10
  <input type="hidden" name="username" value="{{ u }}">
11 -
  <p>Signed in as <strong>{{ u }}</strong>. Enter the six-digit code from your authenticator app, or a recovery code.</p>
11 +
  <input type="hidden" name="pending" value="{{ pending }}">
12 +
  <p>Password accepted for <strong>{{ u }}</strong>. Enter the six-digit code from your authenticator app, or a recovery code.</p>
12 13
  <label for="code">Code</label><input type="text" id="code" name="code" autocomplete="one-time-code" inputmode="numeric" autofocus>
13 -
  <label for="password2">Password (again, to keep this step honest)</label><input type="password" id="password2" name="password" autocomplete="current-password">
14 14
  {% else %}
15 15
  <label for="username">Username</label><input type="text" id="username" name="username" autocomplete="username" autofocus required>
16 16
  <label for="password">Password</label><input type="password" id="password" name="password" autocomplete="current-password" required>
…
6 unchanged lines
modified templates/settings_tokens.html +3 −1
…
11 unchanged lines
12 12
{% endif %}
13 13
{% if tokens.is_empty() %}<p class="empty">No tokens yet.</p>{% else %}
14 14
<div class="table"><table>
15 -
<tr><th>Name</th><th>Scopes</th><th>Repositories</th><th>Created</th><th>Last used</th><th>Expires</th><th></th></tr>
15 +
<tr><th>Name</th><th>Scopes</th><th>Repositories</th><th>Created</th><th>Last used</th><th>Expires</th><th title="What an agent does with this token is told to you">Agent</th><th></th></tr>
16 16
{% for t in tokens %}
17 17
<tr><td>{{ t.name }}</td><td>{{ t.scopes }}</td><td>{% if t.repos.is_empty() %}<span class="mute">all</span>{% else %}{{ t.repos }}{% endif %}</td>
18 18
<td><time title="{{ t.created_ms|when_i }}">{{ t.created_ms|ago_i }}</time></td>
19 19
<td>{% if let Some(u) = t.last_used_ms %}<time title="{{ u|when_i }}">{{ u|ago_i }}</time>{% else %}<span class="mute">never</span>{% endif %}</td>
20 20
<td>{% if let Some(x) = t.expires_ms %}{{ x|when_i }}{% if t.renew_ms.is_some() %} <span class="mute small">renewed on use</span>{% endif %}{% else %}<span class="mute">never</span>{% endif %}</td>
21 +
<td><form class="tiny" method="post" action="/settings/tokens/{{ t.id }}/agent"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button title="{% if t.agent %}Stop telling me what is done with it{% else %}An agent uses this token: tell me what it does{% endif %}">{% if t.agent %}agent ✓{% else %}mark as agent{% endif %}</button></form></td>
21 22
<td><form class="tiny" method="post" action="/settings/tokens/{{ t.id }}/delete"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button>delete</button></form></td></tr>
22 23
{% endfor %}
23 24
</table></div>
…
8 unchanged lines
32 33
  </div>
33 34
  <label for="repos">Only these repositories (comma-separated owner/name; empty for all)</label><input type="text" id="repos" name="repos">
34 35
  <label for="expires_days">Expires after (days; empty for never)</label><input type="number" id="expires_days" name="expires_days" min="1">
36 +
  <label class="choice"><input type="checkbox" name="agent" value="1"> An agent uses this token: tell me what it does <span class="small mute">(proposals it opens, comments it makes and the rest reach your inbox and email like anyone else's would)</span></label>
35 37
  <button type="submit">CREATE TOKEN</button>
36 38
</form>
37 39
{% endblock %}