Phone alerts: a phone registers its Cloud Messaging token and key (PUT /api/v1/user/push), and each notification its account is told goes to it sealed to that key, sent keyless as the VM's service account; pins OxygenFlow main (refs oxygenhub-android#16) aeed63c00ad7
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 8755166f1817
- When
- · 2026-10-27 20:26 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
aeed63c00ad7ae8ee6a978b0615d273306d8378a1c279b05fb4b5bc86802bddc- Depends on
- 076c7a7c86b1 0ee5c3fb32eb 12325e1b1020 18e381ea88c2 33d98147511b 380996db84e5 3b1bc27fe805 42cd46c9b09e 42fa95540468 4f40eed0588f 60f11b71a740 9a777fec4ed9 a7e6e8e6094c b5668108487f b64e2537d702 d1727c573f30 d23464c0ac60 de505df5cf8d e9bfea8e26db
- Depended on by
- 40a43921ef42 b7e1be264c0a 70c3d02998a2 a9088323c390
- In bookmarks
- main
- In tags
- v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
- Edits
- 20 atoms, 5 tree operations, 14 files
Browse the tree at this change
added crates/hub/migrations-pg/0101_push_devices.sql +13 −0
| 1 | + | -- Phones that take alerts (oxygenhub-android#16): one per device sign-in, |
|
| 2 | + | -- gone with it when the token is revoked. The Cloud Messaging registration |
|
| 3 | + | -- token addresses the phone; alerts are sealed to its X25519 public key |
|
| 4 | + | -- (hex), so the push service sees only ciphertext. |
|
| 5 | + | CREATE TABLE push_devices ( |
|
| 6 | + | token_id BIGINT PRIMARY KEY REFERENCES tokens(id) ON DELETE CASCADE, |
|
| 7 | + | account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 8 | + | fcm_token TEXT NOT NULL, |
|
| 9 | + | public_key TEXT NOT NULL, |
|
| 10 | + | created_ms BIGINT NOT NULL, |
|
| 11 | + | updated_ms BIGINT NOT NULL |
|
| 12 | + | ); |
|
| 13 | + | CREATE INDEX push_devices_by_account ON push_devices (account_id); |
added crates/hub/migrations/0101_push_devices.sql +13 −0
| 1 | + | -- Phones that take alerts (oxygenhub-android#16): one per device sign-in, |
|
| 2 | + | -- gone with it when the token is revoked. The Cloud Messaging registration |
|
| 3 | + | -- token addresses the phone; alerts are sealed to its X25519 public key |
|
| 4 | + | -- (hex), so the push service sees only ciphertext. |
|
| 5 | + | CREATE TABLE push_devices ( |
|
| 6 | + | token_id INTEGER PRIMARY KEY REFERENCES tokens(id) ON DELETE CASCADE, |
|
| 7 | + | account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, |
|
| 8 | + | fcm_token TEXT NOT NULL, |
|
| 9 | + | public_key TEXT NOT NULL, |
|
| 10 | + | created_ms INTEGER NOT NULL, |
|
| 11 | + | updated_ms INTEGER NOT NULL |
|
| 12 | + | ); |
|
| 13 | + | CREATE INDEX push_devices_by_account ON push_devices (account_id); |
added crates/hub/src/db/push.rs +46 −0
| 1 | + | //! Phones that take alerts (oxygenhub-android#16). |
|
| 2 | + | ||
| 3 | + | use super::{now_ms, Pool}; |
|
| 4 | + | use sqlx::FromRow; |
|
| 5 | + | ||
| 6 | + | #[derive(Clone, Debug, FromRow)] |
|
| 7 | + | pub struct Device { |
|
| 8 | + | pub token_id: i64, |
|
| 9 | + | pub account_id: i64, |
|
| 10 | + | pub fcm_token: String, |
|
| 11 | + | pub public_key: String, |
|
| 12 | + | } |
|
| 13 | + | ||
| 14 | + | /// Register (or refresh) the phone signed in with token `token_id`. |
|
| 15 | + | pub async fn register(pool: &Pool, token_id: i64, account_id: i64, fcm_token: &str, public_key: &str) -> sqlx::Result<()> { |
|
| 16 | + | let now = now_ms(); |
|
| 17 | + | sqlx::query( |
|
| 18 | + | "INSERT INTO push_devices (token_id, account_id, fcm_token, public_key, created_ms, updated_ms) VALUES ($1, $2, $3, $4, $5, $5) \ |
|
| 19 | + | ON CONFLICT (token_id) DO UPDATE SET fcm_token = excluded.fcm_token, public_key = excluded.public_key, updated_ms = excluded.updated_ms", |
|
| 20 | + | ) |
|
| 21 | + | .bind(token_id) |
|
| 22 | + | .bind(account_id) |
|
| 23 | + | .bind(fcm_token) |
|
| 24 | + | .bind(public_key) |
|
| 25 | + | .bind(now) |
|
| 26 | + | .execute(pool) |
|
| 27 | + | .await?; |
|
| 28 | + | Ok(()) |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | pub async fn forget(pool: &Pool, token_id: i64) -> sqlx::Result<bool> { |
|
| 32 | + | Ok(sqlx::query("DELETE FROM push_devices WHERE token_id = $1").bind(token_id).execute(pool).await?.rows_affected() > 0) |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | /// Forget a registration Cloud Messaging says is gone. |
|
| 36 | + | pub async fn forget_fcm(pool: &Pool, fcm_token: &str) -> sqlx::Result<()> { |
|
| 37 | + | sqlx::query("DELETE FROM push_devices WHERE fcm_token = $1").bind(fcm_token).execute(pool).await?; |
|
| 38 | + | Ok(()) |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | pub async fn of_account(pool: &Pool, account_id: i64) -> sqlx::Result<Vec<Device>> { |
|
| 42 | + | sqlx::query_as("SELECT token_id, account_id, fcm_token, public_key FROM push_devices WHERE account_id = $1") |
|
| 43 | + | .bind(account_id) |
|
| 44 | + | .fetch_all(pool) |
|
| 45 | + | .await |
|
| 46 | + | } |
added crates/hub/src/push.rs +109 −0
| 1 | + | //! Phone alerts (oxygenhub-android#16, oxygenhub-gcp decision 0008): each |
|
| 2 | + | //! notification an account is told also goes to its phones through Firebase |
|
| 3 | + | //! Cloud Messaging, sealed to each phone's key (`flow_core::alert`), so the |
|
| 4 | + | //! push service carries only ciphertext and a wake-up. |
|
| 5 | + | //! |
|
| 6 | + | //! The hub sends as the service account its VM runs as: the metadata |
|
| 7 | + | //! server's token, kept until a minute before it expires. Off while |
|
| 8 | + | //! `[push] fcm_project` is empty. |
|
| 9 | + | ||
| 10 | + | use crate::db::push as pdb; |
|
| 11 | + | use crate::Shared; |
|
| 12 | + | use std::sync::Mutex; |
|
| 13 | + | use std::time::{Duration, Instant}; |
|
| 14 | + | ||
| 15 | + | /// Most phones one account's alert goes to. |
|
| 16 | + | const MOST_DEVICES: usize = 20; |
|
| 17 | + | ||
| 18 | + | static TOKEN: Mutex<Option<(String, Instant)>> = Mutex::new(None); |
|
| 19 | + | ||
| 20 | + | /// What a phone is told, before it is sealed: small, as a message's data |
|
| 21 | + | /// is at most 4 KB. |
|
| 22 | + | #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)] |
|
| 23 | + | pub struct Alert { |
|
| 24 | + | pub kind: String, |
|
| 25 | + | pub title: String, |
|
| 26 | + | pub url: String, |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | /// Whether alerts are on. |
|
| 30 | + | pub fn enabled(app: &Shared) -> bool { |
|
| 31 | + | !app.config.push.fcm_project.is_empty() |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | /// Send `alert` to every phone of `account_id`, in the background. |
|
| 35 | + | pub fn send(app: &Shared, account_id: i64, alert: Alert) { |
|
| 36 | + | if !enabled(app) { |
|
| 37 | + | return; |
|
| 38 | + | } |
|
| 39 | + | let app = app.clone(); |
|
| 40 | + | tokio::spawn(async move { |
|
| 41 | + | if let Err(e) = send_now(&app, account_id, &alert).await { |
|
| 42 | + | tracing::warn!("push: alert for account {account_id}: {e}"); |
|
| 43 | + | } |
|
| 44 | + | }); |
|
| 45 | + | } |
|
| 46 | + | ||
| 47 | + | async fn access_token(app: &Shared, client: &reqwest::Client) -> Result<String, String> { |
|
| 48 | + | if let Some((t, until)) = TOKEN.lock().unwrap().clone() { |
|
| 49 | + | if Instant::now() < until { |
|
| 50 | + | return Ok(t); |
|
| 51 | + | } |
|
| 52 | + | } |
|
| 53 | + | #[derive(serde::Deserialize)] |
|
| 54 | + | struct Got { |
|
| 55 | + | access_token: String, |
|
| 56 | + | expires_in: u64, |
|
| 57 | + | } |
|
| 58 | + | let got: Got = client |
|
| 59 | + | .get(&app.config.push.token_url) |
|
| 60 | + | .header("Metadata-Flavor", "Google") |
|
| 61 | + | .send() |
|
| 62 | + | .await |
|
| 63 | + | .and_then(|r| r.error_for_status()) |
|
| 64 | + | .map_err(|e| format!("the metadata server's token: {e}"))? |
|
| 65 | + | .json() |
|
| 66 | + | .await |
|
| 67 | + | .map_err(|e| format!("the metadata server's token: {e}"))?; |
|
| 68 | + | let until = Instant::now() + Duration::from_secs(got.expires_in.saturating_sub(60)); |
|
| 69 | + | *TOKEN.lock().unwrap() = Some((got.access_token.clone(), until)); |
|
| 70 | + | Ok(got.access_token) |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | /// Send `alert` to each of the account's phones now; a phone Cloud |
|
| 74 | + | /// Messaging no longer knows is forgotten. |
|
| 75 | + | pub async fn send_now(app: &Shared, account_id: i64, alert: &Alert) -> Result<usize, String> { |
|
| 76 | + | let devices = pdb::of_account(&app.db, account_id).await.map_err(|e| e.to_string())?; |
|
| 77 | + | if devices.is_empty() { |
|
| 78 | + | return Ok(0); |
|
| 79 | + | } |
|
| 80 | + | let client = reqwest::Client::builder().timeout(Duration::from_secs(20)).build().map_err(|e| e.to_string())?; |
|
| 81 | + | let token = access_token(app, &client).await?; |
|
| 82 | + | let plain = serde_json::to_vec(alert).map_err(|e| e.to_string())?; |
|
| 83 | + | let url = format!("{}/v1/projects/{}/messages:send", app.config.push.fcm_url.trim_end_matches('/'), app.config.push.fcm_project); |
|
| 84 | + | let mut sent = 0; |
|
| 85 | + | for d in devices.iter().take(MOST_DEVICES) { |
|
| 86 | + | let Some(key) = hex::decode(&d.public_key).ok().and_then(|k| <[u8; 32]>::try_from(k).ok()) else { continue }; |
|
| 87 | + | let Some(sealed) = flow_core::alert::seal(&key, &plain) else { continue }; |
|
| 88 | + | // Data only, high priority: the app wakes, opens it and shows it; |
|
| 89 | + | // the push service has no title or body to show or read. |
|
| 90 | + | let body = serde_json::json!({ "message": { |
|
| 91 | + | "token": d.fcm_token, |
|
| 92 | + | "data": { "sealed": crate::oidc::b64url(&sealed) }, |
|
| 93 | + | "android": { "priority": "high" }, |
|
| 94 | + | }}); |
|
| 95 | + | let r = client.post(&url).bearer_auth(&token).json(&body).send().await.map_err(|e| e.to_string())?; |
|
| 96 | + | let status = r.status(); |
|
| 97 | + | if status.is_success() { |
|
| 98 | + | sent += 1; |
|
| 99 | + | } else { |
|
| 100 | + | let text = r.text().await.unwrap_or_default(); |
|
| 101 | + | if status == reqwest::StatusCode::NOT_FOUND || text.contains("UNREGISTERED") { |
|
| 102 | + | pdb::forget_fcm(&app.db, &d.fcm_token).await.ok(); |
|
| 103 | + | } else { |
|
| 104 | + | tracing::warn!("push: Cloud Messaging said {status}: {}", text.chars().take(200).collect::<String>()); |
|
| 105 | + | } |
|
| 106 | + | } |
|
| 107 | + | } |
|
| 108 | + | Ok(sent) |
|
| 109 | + | } |
added crates/hub/tests/push.rs +158 −0
| 1 | + | //! Phone alerts (oxygenhub-android#16): a phone registers its Cloud |
|
| 2 | + | //! Messaging token and its key with the token it signed in with; each |
|
| 3 | + | //! notification its account is told goes to it sealed to that key, so the |
|
| 4 | + | //! push service sees no title; a registration Cloud Messaging no longer |
|
| 5 | + | //! knows is forgotten, and one goes with its token. |
|
| 6 | + | ||
| 7 | + | use axum::body::Body; |
|
| 8 | + | use axum::http::{header, Request, StatusCode}; |
|
| 9 | + | use axum::routing::{get, post}; |
|
| 10 | + | use axum::{Json, Router}; |
|
| 11 | + | use http_body_util::BodyExt; |
|
| 12 | + | use oxygenhub::config::Config; |
|
| 13 | + | use oxygenhub::db; |
|
| 14 | + | use oxygenhub::Shared; |
|
| 15 | + | use std::sync::{Arc, Mutex}; |
|
| 16 | + | use tower::ServiceExt; |
|
| 17 | + | ||
| 18 | + | type Sent = Arc<Mutex<Vec<serde_json::Value>>>; |
|
| 19 | + | ||
| 20 | + | /// Google's metadata server and Cloud Messaging, as far as the hub uses them. |
|
| 21 | + | async fn fake_google() -> (String, Sent) { |
|
| 22 | + | let sent: Sent = Arc::default(); |
|
| 23 | + | let kept = sent.clone(); |
|
| 24 | + | let app = Router::new() |
|
| 25 | + | .route( |
|
| 26 | + | "/token", |
|
| 27 | + | get(|h: axum::http::HeaderMap| async move { |
|
| 28 | + | assert_eq!(h.get("metadata-flavor").and_then(|v| v.to_str().ok()), Some("Google")); |
|
| 29 | + | Json(serde_json::json!({ "access_token": "vm-token", "expires_in": 3600 })) |
|
| 30 | + | }), |
|
| 31 | + | ) |
|
| 32 | + | .route( |
|
| 33 | + | "/v1/projects/demo/messages:send", |
|
| 34 | + | post(move |h: axum::http::HeaderMap, Json(body): Json<serde_json::Value>| { |
|
| 35 | + | let kept = kept.clone(); |
|
| 36 | + | async move { |
|
| 37 | + | assert_eq!(h.get("authorization").and_then(|v| v.to_str().ok()), Some("Bearer vm-token")); |
|
| 38 | + | let gone = body["message"]["token"] == "gone"; |
|
| 39 | + | kept.lock().unwrap().push(body); |
|
| 40 | + | if gone { |
|
| 41 | + | ( |
|
| 42 | + | StatusCode::NOT_FOUND, |
|
| 43 | + | Json(serde_json::json!({ "error": { "status": "NOT_FOUND", "details": [{ "errorCode": "UNREGISTERED" }] } })), |
|
| 44 | + | ) |
|
| 45 | + | } else { |
|
| 46 | + | (StatusCode::OK, Json(serde_json::json!({ "name": "projects/demo/messages/1" }))) |
|
| 47 | + | } |
|
| 48 | + | } |
|
| 49 | + | }), |
|
| 50 | + | ); |
|
| 51 | + | let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
|
| 52 | + | let base = format!("http://{}", listener.local_addr().unwrap()); |
|
| 53 | + | tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); |
|
| 54 | + | (base, sent) |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | async fn call(app: &Shared, method: &str, path: &str, token: &str, body: Option<serde_json::Value>) -> (StatusCode, String) { |
|
| 58 | + | let mut req = Request::builder().method(method).uri(path).header(header::AUTHORIZATION, format!("Bearer {token}")); |
|
| 59 | + | let body = match body { |
|
| 60 | + | Some(b) => { |
|
| 61 | + | req = req.header(header::CONTENT_TYPE, "application/json"); |
|
| 62 | + | Body::from(b.to_string()) |
|
| 63 | + | } |
|
| 64 | + | None => Body::empty(), |
|
| 65 | + | }; |
|
| 66 | + | let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
|
| 67 | + | let status = res.status(); |
|
| 68 | + | (status, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()) |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | fn b64url_decode(s: &str) -> Vec<u8> { |
|
| 72 | + | let val = |c: u8| match c { |
|
| 73 | + | b'A'..=b'Z' => c - b'A', |
|
| 74 | + | b'a'..=b'z' => c - b'a' + 26, |
|
| 75 | + | b'0'..=b'9' => c - b'0' + 52, |
|
| 76 | + | b'-' => 62, |
|
| 77 | + | _ => 63, |
|
| 78 | + | } as u32; |
|
| 79 | + | let mut out = Vec::new(); |
|
| 80 | + | for chunk in s.as_bytes().chunks(4) { |
|
| 81 | + | let n = chunk.iter().enumerate().fold(0u32, |acc, (i, c)| acc | (val(*c) << (18 - 6 * i))); |
|
| 82 | + | out.extend_from_slice(&n.to_be_bytes()[1..chunk.len()]); |
|
| 83 | + | } |
|
| 84 | + | out |
|
| 85 | + | } |
|
| 86 | + | ||
| 87 | + | async fn sent_after(sent: &Sent, n: usize) -> Vec<serde_json::Value> { |
|
| 88 | + | for _ in 0..100 { |
|
| 89 | + | if sent.lock().unwrap().len() >= n { |
|
| 90 | + | break; |
|
| 91 | + | } |
|
| 92 | + | tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
|
| 93 | + | } |
|
| 94 | + | sent.lock().unwrap().clone() |
|
| 95 | + | } |
|
| 96 | + | ||
| 97 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 98 | + | async fn a_notification_reaches_the_phone_sealed() { |
|
| 99 | + | let (google, sent) = fake_google().await; |
|
| 100 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 101 | + | let mut config = Config::load(dir.path()).unwrap(); |
|
| 102 | + | config.push.fcm_project = "demo".into(); |
|
| 103 | + | config.push.fcm_url = google.clone(); |
|
| 104 | + | config.push.token_url = format!("{google}/token"); |
|
| 105 | + | let app = oxygenhub::build(config).await.unwrap(); |
|
| 106 | + | let bob = db::accounts::create_account(&app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap(); |
|
| 107 | + | let (phone_token, hash) = oxygenhub::auth::new_token(); |
|
| 108 | + | db::accounts::create_token(&app.db, bob.id, "pixel", &hash, "repo:read", "", None).await.unwrap(); |
|
| 109 | + | ||
| 110 | + | // The phone registers its Cloud Messaging token and its key. |
|
| 111 | + | let secret = flow_core::alert::new_secret(); |
|
| 112 | + | let key = hex::encode(flow_core::alert::public_key(&secret)); |
|
| 113 | + | let bad = |
|
| 114 | + | call(&app, "PUT", "/api/v1/user/push", &phone_token, Some(serde_json::json!({ "fcm_token": "f1", "public_key": "abc" }))).await; |
|
| 115 | + | assert_eq!(bad.0, StatusCode::BAD_REQUEST, "{}", bad.1); |
|
| 116 | + | let (status, body) = |
|
| 117 | + | call(&app, "PUT", "/api/v1/user/push", &phone_token, Some(serde_json::json!({ "fcm_token": "f1", "public_key": key }))).await; |
|
| 118 | + | assert_eq!(status, StatusCode::OK, "{body}"); |
|
| 119 | + | assert!(body.contains("\"alerts\":true"), "{body}"); |
|
| 120 | + | ||
| 121 | + | // A notification for bob: his phone is sent it, sealed. |
|
| 122 | + | oxygenhub::notify::tell( |
|
| 123 | + | &app, |
|
| 124 | + | "issue.assigned", |
|
| 125 | + | "andy/demo#1: Fix it", |
|
| 126 | + | "/andy/demo/issue/1", |
|
| 127 | + | "andy", |
|
| 128 | + | None, |
|
| 129 | + | vec![(bob.id, db::notify::Reason::Participating)], |
|
| 130 | + | ) |
|
| 131 | + | .await; |
|
| 132 | + | let got = sent_after(&sent, 1).await; |
|
| 133 | + | assert_eq!(got.len(), 1, "{got:?}"); |
|
| 134 | + | let m = &got[0]["message"]; |
|
| 135 | + | assert_eq!(m["token"], "f1"); |
|
| 136 | + | assert_eq!(m["android"]["priority"], "high"); |
|
| 137 | + | assert!(m.get("notification").is_none() && !got[0].to_string().contains("Fix it"), "nothing readable on the way: {got:?}"); |
|
| 138 | + | let opened = flow_core::alert::open(&secret, &b64url_decode(m["data"]["sealed"].as_str().unwrap())).expect("sealed to the phone"); |
|
| 139 | + | let alert: oxygenhub::push::Alert = serde_json::from_slice(&opened).unwrap(); |
|
| 140 | + | assert_eq!(alert.kind, "issue.assigned"); |
|
| 141 | + | assert_eq!(alert.title, "📌 Issue Assigned: andy/demo#1: Fix it"); |
|
| 142 | + | assert!(alert.url.ends_with("/andy/demo/issue/1") && alert.url.starts_with("http"), "{}", alert.url); |
|
| 143 | + | ||
| 144 | + | // A registration Cloud Messaging no longer knows is forgotten. |
|
| 145 | + | let (gone_token, hash) = oxygenhub::auth::new_token(); |
|
| 146 | + | db::accounts::create_token(&app.db, bob.id, "old phone", &hash, "repo:read", "", None).await.unwrap(); |
|
| 147 | + | call(&app, "PUT", "/api/v1/user/push", &gone_token, Some(serde_json::json!({ "fcm_token": "gone", "public_key": key }))).await; |
|
| 148 | + | assert_eq!(oxygenhub::push::send_now(&app, bob.id, &alert).await, Ok(1)); |
|
| 149 | + | let left: Vec<String> = db::push::of_account(&app.db, bob.id).await.unwrap().into_iter().map(|d| d.fcm_token).collect(); |
|
| 150 | + | assert_eq!(left, ["f1"]); |
|
| 151 | + | ||
| 152 | + | // Unregistering, and signing out (the token revoked), end it. |
|
| 153 | + | assert_eq!(call(&app, "DELETE", "/api/v1/user/push", &phone_token, None).await.0, StatusCode::NO_CONTENT); |
|
| 154 | + | assert!(db::push::of_account(&app.db, bob.id).await.unwrap().is_empty()); |
|
| 155 | + | call(&app, "PUT", "/api/v1/user/push", &phone_token, Some(serde_json::json!({ "fcm_token": "f1", "public_key": key }))).await; |
|
| 156 | + | assert_eq!(call(&app, "DELETE", "/api/v1/user/token", &phone_token, None).await.0, StatusCode::NO_CONTENT); |
|
| 157 | + | assert!(db::push::of_account(&app.db, bob.id).await.unwrap().is_empty(), "gone with its token"); |
|
| 158 | + | } |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Phone alerts (oxygenhub-android#16, oxygenhub-gcp decision 0008).** A phone registers its Cloud Messaging token and an X25519 public key with `PUT /api/v1/user/push`, using the token it signed in with (`DELETE` unregisters; revoking the token ends it too, migration 0101). Each notification an account is told then also goes to its phones, its title and link sealed to each phone's key (`flow_core::alert`), as a data-only, high-priority message, so Google carries only ciphertext. The hub sends as the service account its VM runs as, with the metadata server's token: no key. Off until `[push] fcm_project` (`OXYHUB_PUSH_FCM_PROJECT`) is set; a registration Cloud Messaging no longer knows is forgotten. Pins OxygenFlow main (sealed alerts). |
|
| 8 | + | ||
| 7 | 9 | - **A fork starts green where upstream proved the same state (#296, first slice).** Pass reuse (#265) looked in the repository's own runs only. A push's job with no pass of its own on that state now reuses one from the repositories it was forked from, nearest first (up to five up): the same state is the same changes, so upstream's receipt stands for it. Only from an upstream that is public or has the fork's owner. The run page says whose pass it was and links to it, and its check reads "passed in OWNER/NAME run #N on this state, reused". |
|
| 8 | 10 | ||
| 9 | 11 | - **A proposal's conflicts are resolved on the web (#337).** A proposal whose landing would conflict has RESOLVE ON THE WEB for those who may push to its source: the hub brings the target into the source bookmark, as `oxy merge TARGET` would there (signed as the user's delegate, or unsigned where no trust policy asks for more), and opens the resolver on the first conflicted file. The resolution is committed to the source, so the proposal follows it and lands. A proposal from a fork is resolved in the fork. |
|
| … | 378 unchanged lines |
modified Cargo.lock +2 −0
| … | 1114 unchanged lines |
||
| 1115 | 1115 | version = "0.5.0" |
|
| 1116 | 1116 | dependencies = [ |
|
| 1117 | 1117 | "blake3", |
|
| 1118 | + | "curve25519-dalek", |
|
| 1118 | 1119 | "globset", |
|
| 1119 | 1120 | "hex", |
|
| 1120 | 1121 | "kdl", |
|
| 1121 | 1122 | "miette", |
|
| 1122 | 1123 | "oxy-core", |
|
| 1124 | + | "ring", |
|
| 1123 | 1125 | "serde", |
|
| 1124 | 1126 | "serde_json", |
|
| 1125 | 1127 | "thiserror 1.0.69", |
|
| … | 3749 unchanged lines |
modified crates/hub/src/api/mod.rs +43 −0
| … | 109 unchanged lines |
||
| 110 | 110 | .route("/user/proposals", get(user_proposals)) |
|
| 111 | 111 | .route("/user/runner-token", post(user_runner_token)) |
|
| 112 | 112 | .route("/user/token", axum::routing::delete(revoke_own_token)) |
|
| 113 | + | .route("/user/push", axum::routing::put(register_push).delete(forget_push)) |
|
| 113 | 114 | .route("/version", get(version)) |
|
| 114 | 115 | .route("/notifications", get(notifications)) |
|
| 115 | 116 | .route("/notifications/read", post(notifications_read_all)) |
|
| … | 239 unchanged lines |
||
| 355 | 356 | Ok(StatusCode::NO_CONTENT) |
|
| 356 | 357 | } |
|
| 357 | 358 | ||
| 359 | + | #[derive(Deserialize)] |
|
| 360 | + | struct PushRegistration { |
|
| 361 | + | /// The phone's Cloud Messaging registration token. |
|
| 362 | + | fcm_token: String, |
|
| 363 | + | /// The phone's X25519 public key, hex: alerts are sealed to it. |
|
| 364 | + | public_key: String, |
|
| 365 | + | } |
|
| 366 | + | ||
| 367 | + | /// `PUT /api/v1/user/push` (oxygenhub-android#16): the phone signed in |
|
| 368 | + | /// with this token takes alerts, sealed to its key; registering again |
|
| 369 | + | /// replaces its registration. It ends with the token. |
|
| 370 | + | async fn register_push( |
|
| 371 | + | State(app): State<Shared>, |
|
| 372 | + | headers: HeaderMap, |
|
| 373 | + | Json(r): Json<PushRegistration>, |
|
| 374 | + | ) -> Result<Json<serde_json::Value>, ApiError> { |
|
| 375 | + | let c = caller(&app, &headers).await?; |
|
| 376 | + | let (Some(account), Some(id)) = (c.account.as_ref(), c.token_id) else { |
|
| 377 | + | return Err(ApiError(StatusCode::UNAUTHORIZED, "a phone registers with the token it signed in with".into())); |
|
| 378 | + | }; |
|
| 379 | + | let key = r.public_key.trim().to_ascii_lowercase(); |
|
| 380 | + | if key.len() != 64 || hex::decode(&key).is_err() { |
|
| 381 | + | return Err(ApiError(StatusCode::BAD_REQUEST, "public_key is 32 bytes of hex".into())); |
|
| 382 | + | } |
|
| 383 | + | let fcm = r.fcm_token.trim(); |
|
| 384 | + | if fcm.is_empty() || fcm.len() > 4096 { |
|
| 385 | + | return Err(ApiError(StatusCode::BAD_REQUEST, "fcm_token is the phone's registration token".into())); |
|
| 386 | + | } |
|
| 387 | + | db::push::register(&app.db, id, account.id, fcm, &key).await?; |
|
| 388 | + | Ok(Json(serde_json::json!({ "alerts": crate::push::enabled(&app) }))) |
|
| 389 | + | } |
|
| 390 | + | ||
| 391 | + | /// `DELETE /api/v1/user/push`: this phone takes no more alerts. |
|
| 392 | + | async fn forget_push(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> { |
|
| 393 | + | let c = caller(&app, &headers).await?; |
|
| 394 | + | let Some(id) = c.token_id.filter(|_| c.account.is_some()) else { |
|
| 395 | + | return Err(ApiError(StatusCode::UNAUTHORIZED, "a phone unregisters with the token it signed in with".into())); |
|
| 396 | + | }; |
|
| 397 | + | db::push::forget(&app.db, id).await?; |
|
| 398 | + | Ok(StatusCode::NO_CONTENT) |
|
| 399 | + | } |
|
| 400 | + | ||
| 358 | 401 | /// Who calls: the account behind a bearer token, with the token's scopes. |
|
| 359 | 402 | pub struct Caller { |
|
| 360 | 403 | pub account: Option<Account>, |
|
| … | 2843 unchanged lines |
modified crates/hub/src/config.rs +31 −0
| … | 25 unchanged lines |
||
| 26 | 26 | pub outbound: Outbound, |
|
| 27 | 27 | pub database: Database, |
|
| 28 | 28 | pub tlog: Tlog, |
|
| 29 | + | pub push: Push, |
|
| 29 | 30 | } |
|
| 30 | 31 | ||
| 31 | 32 | /// Where a hub built with the `postgres` feature keeps its tables (#211); a |
|
| … | 18 unchanged lines |
||
| 50 | 51 | pub allow: Vec<String>, |
|
| 51 | 52 | } |
|
| 52 | 53 | ||
| 54 | + | /// Phone alerts (oxygenhub-android#16, oxygenhub-gcp decision 0008): a |
|
| 55 | + | /// notification is also sent, sealed to each phone's key, through Firebase |
|
| 56 | + | /// Cloud Messaging. Off while `fcm_project` is empty. The hub sends as the |
|
| 57 | + | /// service account its VM runs as, whose token the metadata server gives: |
|
| 58 | + | /// no key. |
|
| 59 | + | #[derive(Clone, Debug, Deserialize)] |
|
| 60 | + | #[serde(default, deny_unknown_fields)] |
|
| 61 | + | pub struct Push { |
|
| 62 | + | /// The Firebase (GCP) project, `OXYHUB_PUSH_FCM_PROJECT`. |
|
| 63 | + | pub fcm_project: String, |
|
| 64 | + | /// Cloud Messaging and the token source; set only to point them |
|
| 65 | + | /// elsewhere (tests). |
|
| 66 | + | pub fcm_url: String, |
|
| 67 | + | pub token_url: String, |
|
| 68 | + | } |
|
| 69 | + | ||
| 70 | + | impl Default for Push { |
|
| 71 | + | fn default() -> Self { |
|
| 72 | + | Push { |
|
| 73 | + | fcm_project: String::new(), |
|
| 74 | + | fcm_url: "https://fcm.googleapis.com".into(), |
|
| 75 | + | token_url: "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token".into(), |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | ||
| 53 | 80 | /// The transparency log's witnesses (docs/transparency.md, #238): who |
|
| 54 | 81 | /// cosigns this hub's checkpoints, and whose logs this hub cosigns. |
|
| 55 | 82 | #[derive(Clone, Debug, Default, Deserialize)] |
|
| … | 424 unchanged lines |
||
| 480 | 507 | outbound: Outbound::default(), |
|
| 481 | 508 | database: Database::default(), |
|
| 482 | 509 | tlog: Tlog::default(), |
|
| 510 | + | push: Push::default(), |
|
| 483 | 511 | } |
|
| 484 | 512 | } |
|
| 485 | 513 | } |
|
| … | 24 unchanged lines |
||
| 510 | 538 | } |
|
| 511 | 539 | if let Some(v) = get("OXYHUB_DATABASE_SCHEMA") { |
|
| 512 | 540 | self.database.schema = v; |
|
| 541 | + | } |
|
| 542 | + | if let Some(v) = get("OXYHUB_PUSH_FCM_PROJECT") { |
|
| 543 | + | self.push.fcm_project = v; |
|
| 513 | 544 | } |
|
| 514 | 545 | if let Some(v) = get("OXYHUB_SITE_NAME") { |
|
| 515 | 546 | self.site.name = v; |
|
| … | 153 unchanged lines |
modified crates/hub/src/db/mod.rs +1 −0
| … | 9 unchanged lines |
||
| 10 | 10 | pub mod notify; |
|
| 11 | 11 | pub mod projects; |
|
| 12 | 12 | pub mod proposals; |
|
| 13 | + | pub mod push; |
|
| 13 | 14 | pub mod push_protection; |
|
| 14 | 15 | pub mod queue; |
|
| 15 | 16 | pub mod releases; |
|
| … | 156 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 38 unchanged lines |
||
| 39 | 39 | pub mod oidc; |
|
| 40 | 40 | pub mod protocol; |
|
| 41 | 41 | pub mod provenance; |
|
| 42 | + | pub mod push; |
|
| 42 | 43 | pub mod push_protection; |
|
| 43 | 44 | pub mod queue; |
|
| 44 | 45 | pub mod render; |
|
| … | 384 unchanged lines |
modified crates/hub/src/notify/mod.rs +3 −0
| … | 85 unchanged lines |
||
| 86 | 86 | for (id, reason) in targets { |
|
| 87 | 87 | ndb::add(pool, ndb::New { account_id: id, repo_id, reason, kind, title, url, actor }).await.ok(); |
|
| 88 | 88 | app.bus.live(&crate::events::topic::user(id), "notification"); |
|
| 89 | + | // And to the account's phones, sealed (oxygenhub-android#16). |
|
| 90 | + | let link = format!("{}{url}", app.config.base_url().trim_end_matches('/')); |
|
| 91 | + | crate::push::send(app, id, crate::push::Alert { kind: kind.to_string(), title: subject(kind, title), url: link }); |
|
| 89 | 92 | if let Ok(Some(a)) = db::accounts::account_by_id(pool, id).await { |
|
| 90 | 93 | // Only to addresses the account has proven it reads (hub spec §5.1.1). |
|
| 91 | 94 | if a.email_notifications && a.email_verified && !a.email.is_empty() && crate::mail::enabled(app) { |
|
| … | 70 unchanged lines |
modified crates/hub/src/oidc.rs +1 −1
| … | 218 unchanged lines |
||
| 219 | 219 | out |
|
| 220 | 220 | } |
|
| 221 | 221 | ||
| 222 | - | fn b64url(bytes: &[u8]) -> String { |
|
| 222 | + | pub(crate) fn b64url(bytes: &[u8]) -> String { |
|
| 223 | 223 | const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; |
|
| 224 | 224 | let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); |
|
| 225 | 225 | for chunk in bytes.chunks(3) { |
|
| … | 103 unchanged lines |
replaced deps/oxygenflow +0 −0
No line changes.