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 +44 −13
| … | 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) |
|
| 219 | - | .execute(pool) |
|
| 220 | - | .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 | - | } |
|
| 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) |
|
| 243 | + | .execute(pool) |
|
| 244 | + | .await?; |
|
| 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 %} |