OXYGENHUB

fix(web): signed-in pages say Cache-Control: private, no-store (closes #158) 33bd0bd45f8a

A page for a signed-in viewer may list private repositories, and the
dashboard's sky carries their activity. The hub relied on Cloudflare not
caching HTML; now an HTML response to a request with a session cookie that
sets no Cache-Control of its own says private, no-store. Tests prove the
sky holds only the viewer's own repositories, and that there is none
signed out.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 18:27 UTC
Signature
✔ signed by Andy McCall
Hash
33bd0bd45f8a872f62627eadce4c910490dc48206381958a7bf44f0e622e0112
Depends on
17ca6cddc534 18e381ea88c2 25ab8c73514a 39442b000e6b b5668108487f d23464c0ac60 eb2a3502601d f8cf54e99879
Depended on by
bff9106edcba 6246d1e51f4f 0a3b244b748f 61ecbd299f22
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
6 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
5 unchanged lines
6 6
7 7
- **Diffusion (#148, docs/diffusion.md).** When a repository that others pin as a subrepo moves on, each consumer that follows it gets a proposal moving the pin, signed by the hub, tested by the consumer's own pipeline, with the upstream's changes listed. One proposal per consumer and subrepo, kept up to date; never landed by itself. Turned on per repository (Settings › Diffusion, `PUT /repos/{owner}/{name}/diffusion`, or `follow` in `.oxymodules`), off by default; `green` waits for the upstream's run to pass. A repository's page shows who uses it and how far behind each is (Diffusion › Diffuses to, `GET /repos/{owner}/{name}/consumers`). Consumers are bumped on the bookmark their work lands on, learned from where their proposals land.
8 8
- **Diffusion in the API (#153):** `GET /repos/{owner}/{name}/diffusion` gives what a repository takes from and what it diffuses to, each with its pill, for the VS Code extension.
9 +
- **Your sky (#157, docs/backgrounds.md):** a quiet, moving backdrop behind the dashboard, drawn in Rust compiled to WebAssembly: your repositories as clusters and their last 30 days (every change pushed, proposal and issue events, releases, Flow runs) as marks around them; the cursor lights and links them, and names the cluster it rests by. Settings › Appearance: Moving, Still or Off; still under reduced motion.
10 +
- **Signed-in pages are private (#158):** HTML for a signed-in viewer says `Cache-Control: private, no-store`, so no proxy or CDN serves one viewer's page to another.
9 11
- **The dashboard in cards (#155):** each section a rounded card with its heading and count in a shaded bar, your repositories a sidebar card with New in its heading.
10 12
- **Tidier pages (#152).** The repository sidebar is a column of cards with shaded headings: Diffusion in one card (Takes from and Diffuses to, each with its pill), the ten most recently moved bookmarks with a link to the rest, and the hub's delegate key shown with the OxygenHub mark. The code tree has a shaded head with the latest change and the history, and folder and file icons; the site bar has icons; the Diffusion tables share one column grid; Help › Coming from GitHub covers submodules and Renovate.
11 13
- **Admin › Runners › Images (#146):** every runner image, where it is and what uses it.
…
164 unchanged lines
modified crates/hub/src/lib.rs +22 −1
…
144 unchanged lines
145 145
146 146
/// The full router: pages, API, protocol and static assets.
147 147
pub fn router(app: Shared) -> axum::Router {
148 -
    web::router(app).layer(axum::middleware::from_fn(slow_requests))
148 +
    web::router(app).layer(axum::middleware::from_fn(private_pages)).layer(axum::middleware::from_fn(slow_requests))
149 149
}
150 150
151 151
/// Requests slower than this are logged, with their path (#145).
…
12 unchanged lines
164 164
    res
165 165
}
166 166
167 +
/// A page for a signed-in viewer is theirs alone: it may list private
168 +
/// repositories, and the dashboard's sky carries their activity (#157). An
169 +
/// HTML response to a request with a session cookie, which has not said
170 +
/// how to cache itself, says `private, no-store`, so no shared cache (a
171 +
/// proxy or a CDN in front of the hub) ever serves it to anyone else.
172 +
async fn private_pages(req: axum::extract::Request, next: axum::middleware::Next) -> axum::response::Response {
173 +
    use axum::http::header;
174 +
    let signed_in = req
175 +
        .headers()
176 +
        .get_all(header::COOKIE)
177 +
        .iter()
178 +
        .filter_map(|v| v.to_str().ok())
179 +
        .any(|c| c.split(';').any(|kv| kv.trim_start().starts_with("oxyhub=")));
180 +
    let mut res = next.run(req).await;
181 +
    let html = res.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok()).is_some_and(|t| t.starts_with("text/html"));
182 +
    if signed_in && html && !res.headers().contains_key(header::CACHE_CONTROL) {
183 +
        res.headers_mut().insert(header::CACHE_CONTROL, header::HeaderValue::from_static("private, no-store"));
184 +
    }
185 +
    res
186 +
}
187 +
167 188
/// Serve on the configured addresses until the process is told to stop.
168 189
pub async fn serve(app: Shared) -> anyhow::Result<()> {
169 190
    let router = router(app.clone());
…
33 unchanged lines
modified crates/hub/tests/issues.rs +13 −0
…
382 unchanged lines
383 383
    let events = data["events"].as_array().unwrap();
384 384
    assert!(events.iter().any(|e| e[1] == 1), "the proposals opened are marks: {data}");
385 385
    assert!(events.iter().all(|e| e[0].as_u64().unwrap() < repos.len() as u64));
386 +
    // His page is his alone: no shared cache may keep it (#157).
387 +
    let res = oxygenhub::router(app.clone())
388 +
        .oneshot(Request::builder().uri("/").header(header::COOKIE, format!("oxyhub={andy}")).body(Body::empty()).unwrap())
389 +
        .await
390 +
        .unwrap();
391 +
    assert_eq!(res.headers()[header::CACHE_CONTROL], "private, no-store");
392 +
    // Signed out there is no sky at all.
393 +
    assert!(!anon.body.contains("backdrop-data"));
386 394
    // The engine is served as WebAssembly.
387 395
    let res = oxygenhub::router(app.clone())
388 396
        .oneshot(Request::builder().uri("/static/backdrop/backdrop_bg.wasm").body(Body::empty()).unwrap())
…
10 unchanged lines
399 407
400 408
    // Bob sees his own proposal on the public repository, never the private one's.
401 409
    let page = get(app, "/", Some(&bob)).await;
410 +
    // His sky holds only his own repositories: never Andy's, public or
411 +
    // private, and nothing of the private one (#157).
412 +
    let start = page.body.find(r#"id="backdrop-data">"#).unwrap() + r#"id="backdrop-data">"#.len();
413 +
    let sky = &page.body[start..start + page.body[start..].find("</script>").unwrap()];
414 +
    assert!(!sky.contains("andy/secret") && !sky.contains("andy/demo"), "{sky}");
402 415
    assert!(page.body.contains("Your proposals</h2><span class=\"count-chip\">1</span>"), "{}", page.body);
403 416
    assert!(!page.body.contains("Hidden work"));
404 417
    assert!(page.body.contains("Waiting for your review</h2><span class=\"count-chip\">0</span>"));
…
548 unchanged lines