OXYGENHUB

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">&lt;{{ c.view.author.email }}&gt;</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">&lt;{{ c.view.author.email }}&gt;</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