OXYGENHUB

API: the notifications inbox 42a0ba4bac4e resolution

GET /notifications returns the caller's unread count and newest entries
(unread first, absolute URLs); POST /notifications/{id}/read and
/notifications/read mark one or all read. All need user:read, since
they touch only the caller's own inbox. Notification and Inbox join the
hub-api types.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-28 05:42 UTC
Signature
✔ signed by Andy McCall
Hash
42a0ba4bac4ea65c46cead946134499cff5bd4d6f8043eebdbe156cd73f05231
Depends on
3f02e5ea4f2d 5c6eb1feaa71 93be9ce4653e abb98fa9964e b64e2537d702 c40c2d3edb51 d23464c0ac60
Depended on by
041a945f55f8 ecac6e8d9b41 0b20f1ec4007 e9bfea8e26db 76f262e1a666 756fe44a0f4d eb0c44a9ad37
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 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
11 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/hub-api/src/lib.rs +23 −0
…
254 unchanged lines
255 255
    pub resolved: bool,
256 256
}
257 257
258 +
/// One entry in the caller's inbox (hub spec §10).
259 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
260 +
pub struct Notification {
261 +
    pub id: i64,
262 +
    pub at_ms: i64,
263 +
    /// `issue.opened`, `proposal.comment`, `mention`, `review`, …
264 +
    pub kind: String,
265 +
    pub title: String,
266 +
    /// The page on the hub, absolute.
267 +
    pub url: String,
268 +
    pub actor: String,
269 +
    pub read: bool,
270 +
}
271 +
272 +
/// `GET /notifications`: the unread count and the newest entries, unread first.
273 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
274 +
pub struct Inbox {
275 +
    pub unread: i64,
276 +
    pub items: Vec<Notification>,
277 +
}
278 +
258 279
/// A file a proposal changes, between the target's state and the
259 280
/// projected state (hub spec §7.2). Fetch either side's bytes from
260 281
/// `/proposals/{number}/content/{base|head}/{path}`.
…
369 unchanged lines
630 651
        ("Milestone", schemars::schema_for!(Milestone)),
631 652
        ("Comment", schemars::schema_for!(Comment)),
632 653
        ("ProposalFile", schemars::schema_for!(ProposalFile)),
654 +
        ("Notification", schemars::schema_for!(Notification)),
655 +
        ("Inbox", schemars::schema_for!(Inbox)),
633 656
        ("Atom", schemars::schema_for!(Atom)),
634 657
        ("Review", schemars::schema_for!(Review)),
635 658
        ("TreeEntry", schemars::schema_for!(TreeEntry)),
…
29 unchanged lines
modified crates/hub-api/ts/index.d.ts +15 −0
…
162 unchanged lines
163 163
  removed: number;
164 164
};
165 165
166 +
export type Notification = {
167 +
  actor: string;
168 +
  at_ms: number;
169 +
  id: number;
170 +
  kind: string;
171 +
  read: boolean;
172 +
  title: string;
173 +
  url: string;
174 +
};
175 +
176 +
export type Inbox = {
177 +
  items: Notification[];
178 +
  unread: number;
179 +
};
180 +
166 181
export type Review = {
167 182
  author: string;
168 183
  body: string;
…
227 unchanged lines
modified crates/hub/src/api/mod.rs +50 −0
…
17 unchanged lines
18 18
pub fn router() -> Router<Shared> {
19 19
    Router::new()
20 20
        .route("/user", get(me))
21 +
        .route("/notifications", get(notifications))
22 +
        .route("/notifications/read", post(notifications_read_all))
23 +
        .route("/notifications/{id}/read", post(notification_read))
21 24
        .route("/users/{name}", get(user))
22 25
        .route("/orgs/{name}", get(org))
23 26
        .route("/search", get(search))
…
221 unchanged lines
245 248
    Ok(Json(user_of(&app, a).await?))
246 249
}
247 250
251 +
#[derive(Deserialize)]
252 +
struct InboxQuery {
253 +
    #[serde(default)]
254 +
    limit: Option<i64>,
255 +
}
256 +
257 +
/// The caller's inbox; reading and clearing it needs `user:read`, since
258 +
/// it touches nothing but the caller's own notifications.
259 +
async fn notifications(
260 +
    State(app): State<Shared>,
261 +
    headers: HeaderMap,
262 +
    Query(q): Query<InboxQuery>,
263 +
) -> Result<Json<hub_api::Inbox>, ApiError> {
264 +
    let c = caller(&app, &headers).await?;
265 +
    let a = c.require("user:read")?;
266 +
    let limit = q.limit.unwrap_or(50).clamp(1, 200);
267 +
    let base = app.config.base_url().trim_end_matches('/').to_string();
268 +
    let items = db::notify::inbox(&app.db, a.id, limit)
269 +
        .await?
270 +
        .into_iter()
271 +
        .map(|n| hub_api::Notification {
272 +
            id: n.id,
273 +
            at_ms: n.at_ms,
274 +
            kind: n.kind,
275 +
            title: n.title,
276 +
            url: if n.url.starts_with('/') { format!("{base}{}", n.url) } else { n.url },
277 +
            actor: n.actor,
278 +
            read: n.read,
279 +
        })
280 +
        .collect();
281 +
    Ok(Json(hub_api::Inbox { unread: db::notify::unread(&app.db, a.id).await?, items }))
282 +
}
283 +
284 +
async fn notification_read(State(app): State<Shared>, headers: HeaderMap, Path(id): Path<i64>) -> Result<StatusCode, ApiError> {
285 +
    let c = caller(&app, &headers).await?;
286 +
    let a = c.require("user:read")?;
287 +
    db::notify::mark_read(&app.db, a.id, Some(id)).await?;
288 +
    Ok(StatusCode::NO_CONTENT)
289 +
}
290 +
291 +
async fn notifications_read_all(State(app): State<Shared>, headers: HeaderMap) -> Result<StatusCode, ApiError> {
292 +
    let c = caller(&app, &headers).await?;
293 +
    let a = c.require("user:read")?;
294 +
    db::notify::mark_read(&app.db, a.id, None).await?;
295 +
    Ok(StatusCode::NO_CONTENT)
296 +
}
297 +
248 298
async fn user_of(app: &Shared, a: &Account) -> Result<User, ApiError> {
249 299
    let keys = db::accounts::identities_of(&app.db, a.id).await?.into_iter().map(|i| i.key).collect();
250 300
    Ok(User { username: a.username.clone(), display_name: a.display_name.clone(), is_admin: a.is_admin, keys, created_ms: a.created_ms })
…
938 unchanged lines
modified crates/hub/tests/api.rs +20 −0
…
328 unchanged lines
329 329
    assert!(kinds.contains(&"proposal.opened".to_string()) && kinds.contains(&"check".to_string()), "{kinds:?}");
330 330
    let _ = feat;
331 331
    let _ = rx.fail_first;
332 +
333 +
    // The inbox through the API: count, list, mark one read, mark all read.
334 +
    let bob_user = mint(bob.clone(), "scopes=user%3Aread").await;
335 +
    let bob_id = oxygenhub::db::accounts::account_by_name(&app.db, "bob").await.unwrap().unwrap().id;
336 +
    oxygenhub::db::notify::mark_read(&app.db, bob_id, None).await.unwrap();
337 +
    oxygenhub::db::notify::add(&app.db, bob_id, "mention", "andy/demo#1: Bug", "/andy/demo/issue/1", "andy").await.unwrap();
338 +
    oxygenhub::db::notify::add(&app.db, bob_id, "review", "andy/demo#2: Feat", "/andy/demo/proposal/2", "andy").await.unwrap();
339 +
    let (st, v) = api(app, "GET", "/api/v1/notifications", Some(&bob_user), None).await;
340 +
    assert_eq!(st, StatusCode::OK, "{v}");
341 +
    assert_eq!(v["unread"], 2);
342 +
    assert_eq!(v["items"][0]["read"], false);
343 +
    assert!(v["items"][0]["url"].as_str().unwrap().starts_with(&h.base), "absolute urls: {v}");
344 +
    let first = v["items"][0]["id"].as_i64().unwrap();
345 +
    let (st, _) = api(app, "POST", &format!("/api/v1/notifications/{first}/read"), Some(&bob_user), None).await;
346 +
    assert_eq!(st, StatusCode::NO_CONTENT);
347 +
    assert_eq!(api(app, "GET", "/api/v1/notifications", Some(&bob_user), None).await.1["unread"], 1);
348 +
    assert_eq!(api(app, "POST", "/api/v1/notifications/read", Some(&bob_user), None).await.0, StatusCode::NO_CONTENT);
349 +
    assert_eq!(api(app, "GET", "/api/v1/notifications", Some(&bob_user), None).await.1["unread"], 0);
350 +
    // Another account's notification is not the caller's to clear, and a token without user:read sees nothing.
351 +
    assert_eq!(api(app, "GET", "/api/v1/notifications", Some(&bob_rw), None).await.0, StatusCode::FORBIDDEN);
332 352
}
modified docs/spec.md +1 −1
…
365 unchanged lines
366 366
367 367
### 12.1 API
368 368
369 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses, cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history`, `/compare`, `/conflicts`, `/proposals`, `/proposals/{n}/comments|reviews|checks|files|land`, `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues`, `/labels`, `/milestones`, `/releases`, `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/users/{name}`, `/orgs/{name}`, `/search`. Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
369 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses, cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history`, `/compare`, `/conflicts`, `/proposals`, `/proposals/{n}/comments|reviews|checks|files|land`, `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues`, `/labels`, `/milestones`, `/releases`, `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/search`. Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
370 370
371 371
### 12.2 Webhooks
372 372
…
172 unchanged lines
modified templates/style.css +2 −2
…
12 unchanged lines
13 13
  /* Oxygen's mark: cyan #00F0FF to blue #0072FF on #070c18. */
14 14
  --bg:#070c18; --panel:#0e1526; --soft:#141d33; --ink:#e6ecf7; --mute:#8592ad; --line:#1f2b47; --dim:#43506d;
15 15
  --acc:#00e0f0; --acc-2:#0072ff; --band:#0b3f9e; --band-ink:#ffffff; --band-acc:#00f0ff; --band-line:rgba(7,12,24,.5); --now:#00f0ff; --now-ink:#070c18;
16 -
<<<<<<< oxy conflict 1 of 2, side 1 of 2: deleted by change c7d596c4 "Theme switch, auto theme, and a highlighting fix"
16 +
  --ok:#5fe3a1; --bad:#ff5e7a; --warn:#b48cff;
17 17
-------
18 18
  --acc:#00e0f0; --acc-2:#0072ff; --band:#0b3f9e; --band-ink:#ffffff; --band-line:rgba(7,12,24,.5); --now:#00f0ff; --now-ink:#070c18;
19 19
======= oxy conflict 1 of 2, side 2 of 2: change 3a88f8fc "Theme: violet instead of amber for warnings, revoked keys and syntax constants"
…
3 unchanged lines
23 23
  --m1:#00f0ff; --m2:#d6dcea; --m3:#5aa8ff; --m4:#0072ff; --m5:#8592ad;
24 24
  /* syntax */
25 25
  --sy-kw:#7fb4ff; --sy-str:#8ff0d8; --sy-num:#9fd3ff; --sy-cmt:#5b6785; --sy-fn:#00e0f0; --sy-ty:#c6a0ff; --sy-var:#e6ecf7; --sy-const:#b48cff;
26 -
  color-scheme: dark;
26 +
  --ok:#1f8f5f; --bad:#c62d47; --warn:#6b3fd1;
27 27
}
28 28
:root[data-theme="light"] {
29 29
  --bg:#f4f7fc; --panel:#ffffff; --soft:#e9eef8; --ink:#1b2333; --mute:#66718a; --line:#d6dded; --dim:#b3bccf;
…
302 unchanged lines