OXYGENHUB

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.