Merge main into the phone connections branch
ci / build and test (push) Canceled after 46s
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s

Main had already taken this branch's first two commits, so what merged is the
Connections work against three things that landed beside it. Four of the six
conflicts were prose about arrangements both sides changed; two were real.

**The phone hub gained a Teams row while this branch was moving the keychain
onto it.** Both are additions to `IsMoreSurface` and both belong: teams because
the desktop reaches them from its rail and the phone through the hub, the
keychain because a bottom bar is for the places a session moves between. The
membership test, the back gesture's first case and the hub's own arithmetic all
take the union. The distinction is now written down rather than implied — teams
is the design's count plus one, and the keychain is the only rearrangement of
it: the bar lost a slot to gain that row.

**`ConnectAndAnnounceAsync` was the real one.** Main gave it
`RememberTypedPasswordAsync`, which binds the password that just worked to the
host it worked on; this branch had replaced the `HostRowViewModel` that method
needs with a four-field `ConnectionTarget`. Keeping both meant deciding what a
manual connection does with a password that succeeded, and the answer was
already written on the screen it is typed into: nothing. There is no item to
bind a credential to and none to bind it on, and that path saves nothing by
design.

So `ConnectionTarget` carries the row again — as a nullable, in place of the
host id it had, with `HostId` derived from it. Two things read it and both are
things that can only be done to a keychain item rather than to an address:
naming the log entry, and keeping the password. Null is not missing data there;
it is the whole of what makes the manual path different, and having one field
rather than two keeps "was this a keychain host" a question with one answer.

The desktop's rail lost SFTP and S3 to the tab strip on main, so the README's
"a rail with nine slots has room" was true when it was written this afternoon
and is not now. It says the room rather than the number.

Phase 11's four new device checks and main's Phase 12 on teams were the same
conflict twice — two appends to the end of one file — and both are kept.

Verified after resolving: the solution builds, the Android head builds clean,
and 837 tests pass across the seven client suites, including main's own additions
(233 shell, 79 layout, 240 domain, 118 sync, 54 session, 74 terminal, 39
storage).
This commit is contained in:
2026-08-03 15:35:49 +02:00
88 changed files with 9866 additions and 1707 deletions
+100 -17
View File
@@ -148,8 +148,9 @@ dotnet run --project src/DodoSSH.Client.App
In the app, enter `http://localhost:5233` as the server. Your browser opens for sign-in — the realm ships
`alice` / `alice` — then choose a vault passphrase and **write down the recovery code**, which cannot be
skipped and cannot be recovered from the server. You can then add a host and open a shell on it — double-click
it in the sidebar, or select it and press **CONNECT**, which is the same command with the password box beside
it. Keycloak's admin console is at `http://localhost:18080` (`admin` / `admin`).
its card, or select it and press **CONNECT** in the drawer that opens beside the grid, which is the same
command with the password box above it. Keycloak's admin console is at `http://localhost:18080`
(`admin` / `admin`).
You can also add an SSH key, which is stored in the vault like a host and synced the same way: paste the
private key, then edit a host and pick that key from its **key** dropdown. From then on that host
@@ -226,8 +227,8 @@ bastion — the last needs jump hosts the connection layer has not got. All thre
### Working as a team
**TEAMS** in the nav rail creates a team, adds members and shares vaults. One distinction runs through the
whole screen and is worth having before you use it.
**TEAMS** in the nav rail creates a team, adds and invites members, shares vaults, hands a team over and
archives one. One distinction runs through the whole screen and is worth having before you use it.
**Adding somebody to a team and giving them a key are two different acts, and only the first is something
the server can do.** Adding a member changes what the server will *serve* them: the team's vaults appear in
@@ -244,7 +245,58 @@ published in a log every other client also reads. **It does not prove the key is
Compare the fingerprint with them over something this server does not carry; that is the only step that
closes it, and the success message says so every time.
Three limits, stated rather than discovered:
**Somebody with no account here yet can be invited, and nothing is sent.** There is one button — **ADD
MEMBER** — and it does whichever of the two applies, because which one applies is a fact about the server's
account table rather than about what you are trying to do. If the directory knows the address, that account
is added straight away. If it does not, the address is invited instead, and the status line says which
happened, because the difference decides what you do next.
An invitation is a standing instruction rather than a message: the next account that signs in with that
address joins this team, at the role you chose. There is no link and no token, because this server has no
outbound mail path and does not pretend otherwise — telling them to go and sign in is your job, over a
channel this server does not carry, and a link nobody can deliver would be worse than no link. An
invitation lasts fourteen days, so an address handed on to whoever takes the job next does not carry a
standing offer for ever; it can be withdrawn until it is taken up; and like adding a member it grants
nothing readable, so somebody still has to press SHARE KEY afterwards.
The one thing the merged button costs is worth knowing. Adding an account the directory knows also hands
you the public key you are about to verify and wrap a vault to, and an invitation cannot do that because
there may be no key yet. So when you are adding somebody *in order to* share a vault with them, the useful
sequence is still the same one: add them, see them appear in the members list, then share.
Inviting an address that already belongs to a member of the team is refused and says so. Inviting one that
merely *has* an account here is not — that would make this a way of asking the server which addresses have
accounts, which is not a question anybody willing to create a team first should be able to put to it. Such
an invitation simply gets claimed sooner: within the hour, on the same sweep that records they were here,
rather than waiting for a first sign-in that has already happened.
**An invitation is only claimed if your identity provider says the address is verified, and there is no way
to relax that.** The access token has to carry `email_verified` as true. Anything else — false, missing, or
sent under another name — claims nothing at all, and no setting turns that off: an invitation decides what
the server will serve, and one that could be taken by anybody able to obtain a token asserting somebody
else's address is a way into a team. **If your invitations never activate, this is the first thing to
check.** They sit at *pending* rather than failing, the server logs a warning each time it declines to
claim one, and the two fixes are on your side: set `Oidc:EmailVerifiedClaim` to whatever your provider
calls the claim if it is not `email_verified`, and make sure the provider puts it in the **access** token
rather than only in the ID token or the userinfo response.
**Ownership is sole, and handing it over is one act.** Transferring names an existing active member: they
become owner and you become an admin, in a single transaction. Not two role changes — promoting first
leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody with the
authority to finish a transfer that stopped in the middle. You are demoted rather than removed, so you keep
your vault key grants; removing you would revoke them and flag every team vault for rekey, and somebody
handing over a team is usually staying in it.
**Archiving a team is refused while it owns a vault, and that is a limit rather than a rough edge.** A team
vault is readable *because* of membership, so archiving a team that still owned vaults would take them away
from everybody holding a key — including you — quietly and all at once. Nothing in this product deletes a
vault, so there is no order of operations that gets past the refusal today, and it says so with a count of
what is in the way rather than failing vaguely. Archiving an empty team takes its memberships and its
outstanding invitations with it, in one transaction. Its name can be changed whenever you like; its slug
cannot, because a slug is unique only among live teams and a rename could take one an archived team is
still holding.
Four limits, stated rather than discovered:
- **Removing a member is not retroactive.** It revokes their grants and flags the team's vaults for rekey,
and blocks future reads. Everything they already pulled is on their machine. Rotate the SSH credentials
@@ -255,6 +307,12 @@ Three limits, stated rather than discovered:
then trust silently for a host you defined. The cost is that each member approves a team host's key once
on each of their machines. Team vaults' pins are still *listed* on the Vault screen, so you can see what
has been trusted.
- **LAST ACTIVE is coarse on purpose.** The server records it at most once per account per hour, so a value
an hour old means "recently" and not "at that moment". That is the granularity the question is really
asked at — whether somebody is still using this deployment — and writing it on every request would put an
UPDATE on the hot path of every authenticated call for a number nobody reads that closely. It is shown
as roughly-when rather than to the minute, because showing it to the minute would be reading a precision
into it that is not there.
Items are filed into one vault at a time. When more than one vault is writable, the host and vault editors
show a picker; it defaults to your personal vault and never moves on its own, because an item put in a team
@@ -270,11 +328,11 @@ fit 360dp.
Its interface is the **v2 design**: destinations in a bottom bar, with the rest one tap deeper behind the
last. The bar is three — **Hosts**, **Connections** and **Settings** — with the keychain, snippets, SFTP,
S3 buckets, logs and preferences behind Settings. A bottom bar is for the places a session moves between,
and managing keys is not one of those. Both heads are on that design now; the desktop's own v2 is a
190-pixel labelled sidebar in place of the icon rail, a centred search box in the titlebar, and session tabs
as pills, and it keeps its Keychain entry — a rail with nine slots has room. Its light theme is not built —
see [`docs/design-import-gaps.md`](docs/design-import-gaps.md) — so the application is dark on both.
S3 buckets, logs, teams and preferences behind Settings. A bottom bar is for the places a session moves
between, and managing keys is not one of those. Both heads are on that design now; the desktop's own v2 is
a 190-pixel labelled nav rail in place of the icon rail, a centred search box in the titlebar, and session
tabs as pills, and it keeps its Keychain entry — its rail has the room. Its light theme is not built — see
[`docs/design-import-gaps.md`](docs/design-import-gaps.md) — so the application is dark on both.
**Connections is where a connection is made, not only where one is shown.** With nothing open it offers a
box taking `user@host` or `user@host:port` and a password, and lists the machines most recently connected
@@ -289,15 +347,31 @@ sessions as pills, and a `+` on the right offering the three connections this ap
shell, a host's files over SFTP, or a bucket. The system back gesture does what the arrow does, and lowers
that menu first if it is open.
Widening the sidebar moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every
Widening the rail moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every
screen exactly the width it was designed against.
A third desktop pass has since moved the furniture. The tab strip belongs to the window rather than to the
terminal: **Vaults**, **SFTP** and **S3** are fixed tabs at its head and open terminals follow them, which
took SFTP and S3 out of the nav rail — they are the two destinations you stay in while something runs. The
hosts screen became a grid of cards, groups above and hosts below, with a right-hand drawer for whichever
host is selected and for both editors; the 268-pixel host sidebar is gone. Text is white rather than the
design's blue-tinted `#E3E7F4`, and the type scale is a point larger.
File transfer **is** here now, in the shape scoped storage allows: one remote pane and the queue, over
either an SFTP host or a bucket. There is no local pane, because there is no browsable local filesystem to
put in one — moving a file *in* from the phone needs the system document picker and is the next piece of
work rather than a thing the screen pretends to do. What is still absent is a host editor and a keychain
item editor, so hosts and keys are created on the desktop and sync down; pins, teams and import have no
phone screen either. Importing an `~/.ssh/config` has no meaning on a phone at all.
work rather than a thing the screen pretends to do. Hosts and groups are made and corrected here now, from
a floating + on the Hosts screen, and both editors are cards in the list's own row rather than dialogs, so
the form never covers the thing it is about. The keychain has no editor of its own: SSH keys and buckets are
created on the desktop and sync down, and the phone will delete an item — behind the same counted
confirmation — without offering to change it. What this head does make, it makes where the need arises
rather than in an editor: a tag from inside a host's editor, and a credential from the connect bar's
remember tick, which stores the password just typed and moves the host onto it. Renaming either is still a
desktop job. Pins and import have no phone screen either, and importing an `~/.ssh/config` has no meaning
on a phone at all. **TEAMS does have one**, behind MORE, and it is there for a reason the design could not
have anticipated: an invitation is claimed by signing in, so somebody being told they have been put in a
team is at least as likely to be holding a phone as sitting at a desktop, and a membership visible only on
a head they have not installed is a membership they cannot see.
**Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than
leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so
@@ -499,7 +573,10 @@ keychain plus a terminal — and the spike that gates all of it.
append-only key log served for clients to verify against, team-owned vaults, and vault key grants
wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team
membership to permissions, so a viewer may pull and may not push; the desktop client reads and syncs
every vault it holds a key for, and a real TEAMS screen replaces the placeholder. See
every vault it holds a key for, and a real TEAMS screen replaces the placeholder. A team can be renamed,
handed to another member, and archived once it owns no vaults; a member row carries when that account was
last here; and an address with no account on this deployment can be invited, joining the moment somebody
signs in with it. See
[Working as a team](#working-as-a-team) for the one distinction the whole design rests on, and the limits worth
knowing before you rely on it; the reasoning is in
[ADR 0009](docs/adr/0009-team-access-model.md).
@@ -508,8 +585,14 @@ keychain plus a terminal — and the spike that gates all of it.
every team vault `RekeyRequired`, and nothing acts on that flag. A rekey re-wraps every item's data key
under a fresh vault key and can only be performed by a client that holds the current one; that is M5's
key rotation. Until it lands the flag is what the interface reads to say a rotation is owed, which is
more honest than a button that only appears to do it. Ownership transfer is absent for the same kind of
reason — the owner cannot be removed or demoted, because nothing can appoint a replacement.
more honest than a button that only appears to do it.
**Ownership transfer is here, and it is one write rather than two.** The member you name becomes owner
and you become an admin, in a single transaction — because ownership is sole, so promoting first leaves
the team owned twice and demoting first leaves it owned by nobody, and there is nobody left with the
authority to finish a transfer that stopped in the middle. Nothing else is touched: you keep your vault
key grants, because removing the outgoing owner would revoke them and flag every team vault for rekey,
which is a much larger act than the one being asked for.
- **M4 — hardening and ops**, packaging, self-hosting guide.
- **M5 — multi-provider OIDC**, key rotation, per-item content keys.