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 |