spec: specify hosted web sign-in and attributed authorship
commit b8fba02
spec: specify hosted web sign-in and attributed authorship
roots.web-signin: a hosted session authenticates only by proving an
enrolled, active member key over a one-time, session-bound challenge in
an SSHSIG namespace distinct from git’s; the payload binds host and
challenge, rebuilt locally by the signing tool. receive.attributed-author:
committer is always the signing identity, author may be an attributed
member, the gate keys off the signer. roots.web-signing gains the
member-authored/server-committed clause; roots.single-node-hosted gains
the web-UI mount behind the front proxy. The development plan’s phase-7
hosted-web-UI gap paragraphs record the closure.
Assisted-by: Claude:claude-fable-5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
No reviews of this commit yet — record a verdict below.
Start a review
docs/development-plan.adoc
@@ -178,17 +178,15 @@
a composition root injects. Signing identity is injected by the
composition root; the crate assumes nothing about network
reachability, so in-process webview embedding stays a supported
- deployment (`roots.web-agnostic`). Gap this phase does not close, not
- assigned to any later phase either: no composition root actually wires
- `ents-web` onto a hosted root — not the single-node hosted root this
- binary already doubles as, deployed at `git.ents.cloud` since Phase 6,
- and not `git-ents-server` (phase 8, whose own row below merely reuses
- this phase's sessions/CSRF code, never mounts the web UI).
- `roots.web-signing`'s server-key indirection is proven only
- against fixture identities in this crate's own tests; "turning the
- single-node hosted root into a hosted web UI," which an earlier
- version of this row asserted as delivered, describes the crate's
- capability, not a running deployment.
+ deployment (`roots.web-agnostic`). The gap this phase shipped with —
+ no composition root wiring `ents-web` onto a hosted root, the
+ server-key indirection proven only against fixture identities — was
+ closed after phase 10 by `git ents serve --hosted`
+ (`roots.single-node-hosted`'s web-UI clause): the single-node hosted
+ root mounts this same crate behind nginx with the mandatory gate, the
+ server's enrolled member key as signing identity, and member sign-in
+ via the CLI-driven challenge flow (`roots.web-signin`,
+ `receive.attributed-author` — edits land as "member via the web").
| `gix-receive`
| 8
@@ -326,11 +324,11 @@
through an identity the composition root injects, never one this
crate resolves itself (`roots.web-signing`, `roots.web-agnostic`); the
same router is provably reachable with no bound socket, driven
- in-process via `tower::ServiceExt::oneshot`. This row's own gap is a
- known exception, not a silent one: no hosted deployment (`git.ents.cloud`
- or `git-ents-server`) actually mounts this web UI yet, so
- `roots.web-signing`'s server-key half stays proven only against
- fixture identities until some later phase's composition root wires it.
+ in-process via `tower::ServiceExt::oneshot`. This row's gap — no
+ hosted deployment mounting the web UI, the server-key half proven only
+ against fixtures — closed after phase 10: `git ents serve --hosted`
+ wires it onto the single-node hosted root at `git.ents.cloud`, with
+ member sign-in (`roots.web-signin`) gating mutations.
* *Phase 8 exit*: the honesty test (`roots.honesty-test`) — the
Postgres/Tigris/durable-queue root, now served by `gix-receive` in
place of git's own `receive-pack`, is wired as a new composition root
docs/spec/receive.adoc
@@ -46,6 +46,19 @@
through uninterpreted until one is.
--
+[role="requirement", id="receive.attributed-author"]
+.Attributed Author, Signing Committer
+--
+A mutation commit's committer MUST be the signing identity — the actor
+whose key produces the commit's `gpgsig` header.
+The author MAY be a distinct attributed member, so history reads
+"<member> via <frontend>" (<<roots.web-signing>>); when no attribution
+applies, author and committer MUST be identical.
+Gate evaluation and authorization (<<gate.tip-signed>>) MUST key off the
+signer, never the author: attribution is provenance for readers of
+history, not authority.
+--
+
[role="requirement", id="receive.multi-ref-atomicity"]
.An Entity Declared Across Multiple Refs Writes Them as One Proposal
--
docs/spec/roots.adoc
@@ -44,6 +44,13 @@
`pre-receive`/`post-receive` hooks call the gate and reconcile obligations
around that write, never through this crate's own `RefStore::transaction`,
to avoid a double-write race against `receive-pack`'s internal ref update.
+This root MUST also mount the web UI: the same `ents-web` frontend the
+local root serves (<<roots.web-agnostic>>), wired over this root's own
+seams with the mandatory gate and the server's member key as signing
+identity (<<roots.web-signing>>), served behind the same host.
+The front proxy MUST route git's smart-HTTP paths to stock git's
+`http-backend` and every other path to the loopback-bound web process;
+the web process MUST NOT add a git transport of its own.
This is not <<roots.hosted>>: that root replaces the `RefStore` and object
store themselves with Postgres and Tigris once scale forces `receive-pack`
out entirely (<<roots.honesty-test>>); this root keeps a real on-disk
@@ -88,6 +95,10 @@
--
A hosted web edit MUST be signed by the server's own member key, and that
key MUST itself be an enrolled member.
+A hosted web edit's commit MUST be authored by the signed-in member
+(<<roots.web-signin>>) and committed by the server identity
+(<<receive.attributed-author>>), so history reads "member via the web" —
+the member is never impersonated as the signer.
The local web UI MUST sign edits with the user's own member key instead;
the server-key indirection used hosted MUST NOT be imported into the local
root.
@@ -148,6 +159,32 @@
(<<roots.web-signing>>) only on behalf of an authenticated session.
--
+[role="requirement", id="roots.web-signin"]
+.Hosted Web Sign-In Proves a Member Key
+--
+A hosted web session MUST become authenticated (<<roots.web-session>>)
+only by proving control of an enrolled, active member key: the server
+issues a one-time challenge bound to exactly one browser session, and the
+member's own tooling signs it under an SSHSIG namespace distinct from
+git's commit-signing namespace, so a sign-in signature can never double
+as a push signature or vice versa.
+A challenge MUST be short-lived and single-use; consuming it MUST be the
+only path to authenticating the session it was bound to.
+The signed payload MUST bind the serving host and the challenge itself,
+so a signature cannot be replayed against another deployment or another
+session, and the signing tool MUST construct that payload locally from
+the host the member addressed, never from bytes the server supplies.
+An authenticated session MUST record only the member's username and
+public key, held in server memory per <<roots.web-session>>; no member
+secret is ever transmitted or stored.
+An unauthenticated hosted session MAY browse read-only and MUST NOT
+reach any signing path (<<roots.web-signing>>); a mutation under a
+session whose member is no longer enrolled and active MUST be refused
+at the time of the mutation, not only at sign-in.
+The local root (<<roots.local>>) MUST NOT expose a sign-in surface: its
+serving identity is the operator's own key, resolved at startup.
+--
+
[role="requirement", id="roots.path-validation"]
.Repository Path Validation
--