Say when a change is signed by a key no account has linked 760a7dd30d6f
The change page tags such an author "key not linked" (before, only an unknown identity was tagged), and points signed-in viewers at Settings → Keys. In the API a change author with a key and no account carries a note saying so and where its owner links it. closes #27
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-09-29 02:16 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
760a7dd30d6f9aafb6f1e73141f54ee5221e854c0e063715b321e84b426d3ded- Depends on
- 19bb31ff1f5c 90326c9601f9 93be9ce4653e b64e2537d702 d23464c0ac60 de505df5cf8d e271a37b629a
- Depended on by
- c8684d44d549 8933faff2043 1f1eb06a9009 65be9f9c3074
- 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
- 9 atoms, 0 tree operations, 6 files
Browse the tree at this change
modified crates/hub-api/src/lib.rs +15 −0
| … | 90 unchanged lines |
||
| 91 | 91 | pub key: String, |
|
| 92 | 92 | /// The hub account this key is linked to, when any. |
|
| 93 | 93 | pub account: Option<String>, |
|
| 94 | + | /// Set when the change is signed by a key no account has linked, and |
|
| 95 | + | /// says how its owner links it. |
|
| 96 | + | #[serde(default, skip_serializing_if = "Option::is_none")] |
|
| 97 | + | pub note: Option<String>, |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | impl Author { |
|
| 101 | + | /// What `note` says for a key that no account has linked. |
|
| 102 | + | pub const UNLINKED: &'static str = |
|
| 103 | + | "signed by a key not linked to any account; its owner links it under Settings → Keys (/settings/keys)"; |
|
| 104 | + | ||
| 105 | + | /// The note for an author with this key and account. |
|
| 106 | + | pub fn note_for(key: &str, account: &Option<String>) -> Option<String> { |
|
| 107 | + | (!key.is_empty() && account.is_none()).then(|| Self::UNLINKED.to_string()) |
|
| 108 | + | } |
|
| 94 | 109 | } |
|
| 95 | 110 | ||
| 96 | 111 | /// Signature status of a change as `oxy verify` reports it. |
|
| … | 786 unchanged lines |
modified crates/hub/src/api/mod.rs +1 −0
| … | 248 unchanged lines |
||
| 249 | 249 | email: c.author.email.clone(), |
|
| 250 | 250 | key: c.author.key.clone(), |
|
| 251 | 251 | account: c.author.account.clone(), |
|
| 252 | + | note: Author::note_for(&c.author.key, &c.author.account), |
|
| 252 | 253 | }, |
|
| 253 | 254 | when_ms: c.when_ms, |
|
| 254 | 255 | deps: c.deps.clone(), |
|
| … | 1384 unchanged lines |
modified crates/hub/src/web/proposals.rs +1 −0
| … | 1135 unchanged lines |
||
| 1136 | 1136 | email: c.author.email.clone(), |
|
| 1137 | 1137 | key: c.author.key.clone(), |
|
| 1138 | 1138 | account: c.author.account.clone(), |
|
| 1139 | + | note: hub_api::Author::note_for(&c.author.key, &c.author.account), |
|
| 1139 | 1140 | }, |
|
| 1140 | 1141 | when_ms: c.when_ms, |
|
| 1141 | 1142 | deps: c.deps.clone(), |
|
| … | 31 unchanged lines |
modified crates/hub/tests/api.rs +38 −0
| … | 844 unchanged lines |
||
| 845 | 845 | StatusCode::OK |
|
| 846 | 846 | ); |
|
| 847 | 847 | } |
|
| 848 | + | ||
| 849 | + | /// A change signed by a key no account has linked says so, on its page |
|
| 850 | + | /// and in the API (feedback 14). |
|
| 851 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 852 | + | async fn changes_signed_by_unlinked_keys_say_so() { |
|
| 853 | + | let h = hub().await; |
|
| 854 | + | let app = &h.app; |
|
| 855 | + | let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 856 | + | .await |
|
| 857 | + | .cookie |
|
| 858 | + | .unwrap(); |
|
| 859 | + | let page = call(app, "GET", "/new", None, Some(&andy), None, None).await; |
|
| 860 | + | call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await; |
|
| 861 | + | let url = format!("{}/andy/demo", h.base); |
|
| 862 | + | let work = tempfile::tempdir().unwrap(); |
|
| 863 | + | let (w, u, home) = (work.path().to_path_buf(), url.clone(), work.path().join(".home")); |
|
| 864 | + | let hash = tokio::task::spawn_blocking(move || { |
|
| 865 | + | let mut c = Repo::init(&w.join("r")).unwrap().with_home(home); |
|
| 866 | + | c.config.set("remote.origin.url", &u).unwrap(); |
|
| 867 | + | c.config.set("remote.origin.token", "site").unwrap(); |
|
| 868 | + | c.save_config().unwrap(); |
|
| 869 | + | c.create_identity("Ada", "[email protected]", oxy_core::repo::IdentityScope::Repo).unwrap(); |
|
| 870 | + | write(&w.join("r"), "a.txt", b"a\n"); |
|
| 871 | + | let h = c.commit("signed by Ada", &[]).unwrap(); |
|
| 872 | + | assert!(c.push("origin", false).unwrap()[0].ok); |
|
| 873 | + | h.to_hex() |
|
| 874 | + | }) |
|
| 875 | + | .await |
|
| 876 | + | .unwrap(); |
|
| 877 | + | let (st, v) = api(app, "GET", &format!("/api/v1/repos/andy/demo/changes/{hash}"), None, None).await; |
|
| 878 | + | assert_eq!(st, StatusCode::OK, "{v}"); |
|
| 879 | + | assert!(v["change"]["author"]["account"].is_null(), "{v}"); |
|
| 880 | + | assert!(v["change"]["author"]["note"].as_str().unwrap().contains("not linked"), "{v}"); |
|
| 881 | + | let page = call(app, "GET", &format!("/andy/demo/change/{hash}"), None, Some(&andy), None, None).await; |
|
| 882 | + | assert!(page.body.contains("key not linked") && page.body.contains("/settings/keys"), "a signed-in viewer is pointed at Settings"); |
|
| 883 | + | let anon = call(app, "GET", &format!("/andy/demo/change/{hash}"), None, None, None, None).await; |
|
| 884 | + | assert!(anon.body.contains("key not linked") && !anon.body.contains("link your key")); |
|
| 885 | + | } |
modified docs/spec.md +2 −2
| … | 177 unchanged lines |
||
| 178 | 178 | ||
| 179 | 179 | ### 5.3 Linked identities |
|
| 180 | 180 | ||
| 181 | - | An Oxygen identity is a signed object holding an Ed25519 + ML-DSA-65 key pair's public halves (spec §14.2). An account links an identity by uploading the identity object (`oxy identity export`) and proving possession: the hub issues a 32-byte challenge and the client signs it with `oxy identity sign-challenge` (§18); the hub verifies both signatures. A linked key is what makes a pushed change *this account's* change: author attribution in every page comes from the change's signing key, not from who held the token that pushed it. A change signed by an unlinked key shows its KeyId and "unlinked key" rather than a user name. |
|
| 181 | + | An Oxygen identity is a signed object holding an Ed25519 + ML-DSA-65 key pair's public halves (spec §14.2). An account links an identity by uploading the identity object (`oxy identity export`) and proving possession: the hub issues a 32-byte challenge and the client signs it with `oxy identity sign-challenge` (§18); the hub verifies both signatures. A linked key is what makes a pushed change *this account's* change: author attribution in every page comes from the change's signing key, not from who held the token that pushed it. A change signed by an unlinked key shows its identity's name (or KeyId when the identity is unknown) with a "key not linked" tag rather than a user name. |
|
| 182 | 182 | ||
| 183 | 183 | An identity can be linked to at most one account. Rotation (`oxy identity rotate`) produces a new identity whose object names the old key; the hub accepts the rotated identity for the same account without a new challenge when the rotation is signed by the old key. |
|
| 184 | 184 | ||
| … | 215 unchanged lines |
||
| 400 | 400 | ||
| 401 | 401 | ### 12.1 API |
|
| 402 | 402 | ||
| 403 | - | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route too), 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` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/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}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}`, repo:write: run a bookmark's current state, the default bookmark when none is named), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/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. |
|
| 403 | + | JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route too), 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` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/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}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}`, repo:write: run a bookmark's current state, the default bookmark when none is named), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). 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. |
|
| 404 | 404 | ||
| 405 | 405 | ### 12.2 Webhooks |
|
| 406 | 406 | ||
| … | 178 unchanged lines |
modified templates/change.html +1 −1
| … | 8 unchanged lines |
||
| 9 | 9 | <div class="head"><h1>{{ c.view.title }} <small class="px">{{ c.view.short }}</small></h1></div> |
|
| 10 | 10 | {{ body_html|safe }} |
|
| 11 | 11 | <dl class="facts"> |
|
| 12 | - | <dt>Author</dt><dd>{% if let Some(a) = c.view.author.account %}<a href="/{{ a }}">{{ c.view.author.name }}</a> <span class="tag">@{{ a }}</span>{% else %}{{ c.view.author.name }}{% endif %}{% if !c.view.author.email.is_empty() %} <span class="mute"><{{ c.view.author.email }}></span>{% endif %}{% if c.view.author.unknown && !c.view.author.key.is_empty() %} <span class="tag">unlinked key</span>{% endif %}</dd> |
|
| 12 | + | <dt>Author</dt><dd>{% if let Some(a) = c.view.author.account %}<a href="/{{ a }}">{{ c.view.author.name }}</a> <span class="tag">@{{ a }}</span>{% else %}{{ c.view.author.name }}{% endif %}{% if !c.view.author.email.is_empty() %} <span class="mute"><{{ c.view.author.email }}></span>{% endif %}{% if !c.view.author.key.is_empty() && c.view.author.account.is_none() %} <span class="tag" title="The key that signed this change is not linked to any account.">key not linked</span>{% if shell.user.is_some() %} <a class="small" href="/settings/keys">link your key</a>{% endif %}{% endif %}</dd> |
|
| 13 | 13 | <dt>When</dt><dd><time title="{{ c.view.when_ms|when }}">{{ c.view.when_ms|ago }}</time> · {{ c.view.when_ms|when }}</dd> |
|
| 14 | 14 | <dt>Signature</dt><dd>{% call m::sig(c.view.signature) %} {{ c.view.signature.title() }}</dd> |
|
| 15 | 15 | <dt>Hash</dt><dd><code class="full">{{ c.view.hash }}</code> <button class="small" data-copy="{{ c.view.hash }}">copy</button></dd> |
|
| … | 11 unchanged lines |