Merge main into the desktop redesign branch
ci / build and test (push) Successful in 1m26s
ci / android head (push) Failing after 5s
ci / api image (push) Successful in 20s

Four conflicts. Three were two people adding to the same spot, and one was a
real collision: main gave the connect bar a REMEMBER tick in the same pass that
this branch took the connect bar off the hosts screen.

REMEMBER is now in the drawer, beside the password box it qualifies. Nothing
about the feature changed — RemembersConnectPassword, its refusal to fire until
the remote has accepted the password, and the six tests over it are main's
untouched — only where it is drawn. The move improves it slightly and it is
worth saying why rather than claiming a merge was neutral: the bar had one row
and had to fit the box, the tick, the authentication note and CONNECT along it,
which is why the tick was a bare "REMEMBER" in tracked capitals. A column has
room to put it under the box as a sentence, where it reads as a property of the
password rather than as a fourth control in a row.

MainWindowViewModel: both sides added members after ShowTerminal — the desktop's
three fixed tabs here, the phone's connect menu on main. They do not interact,
so both are kept, each under its own heading.

TeamsScreen: main added the team's own RENAME, HAND OVER and ARCHIVE, a member's
LastActive, the role a new member arrives as, and the KEY HOLDERS list. This
branch had only bumped the file's font sizes a point. Resolved by taking main's
file whole and re-running the bump over it, so the new controls join the scale
rather than sitting a point below everything around them.

README: both sides described a different head's third pass in the same
paragraph. Both kept.

Two things checked rather than assumed, because this branch moved the furniture
the merged commits sit on. The chrome heights main's terminal work touched are
the phone's, not the desktop's — 44, 42 and 24 are unchanged, so the layout
harness's budget still describes the window. And main's keychain DELETE did not
reach VaultScreen.axaml, whose header this branch rearranged, so the five
buttons that overflowed at the larger type are still five.

2415 tests pass, up from 2369 by the 46 main brought.
This commit is contained in:
2026-08-03 15:17:55 +02:00
62 changed files with 8175 additions and 413 deletions
+206
View File
@@ -898,3 +898,209 @@ Focus a passphrase box, then turn the phone sideways.
**Pass:** the box is still visible and still focused, and the shell is intact — the activity handles the
rotation rather than being recreated, and live shells survive it.
---
## Phase 11 — The phone's terminal surface
Every check here needs a real device for the reason Phase 10's do, plus one of its own: the interesting
question on this screen is whether a native `WebView` composites above what Avalonia draws over it, and no
headless surface has a native view to answer with. `docs/android-port.md` still records that as unverified;
11.2 is the check that settles it.
### 11.1 A shell gets the screen · **the important one for chrome**
Open a shell from HOSTS.
**Pass:** the vault header, the session strip and the four-entry bottom bar are all gone. What is left is
one bar — a back arrow, the session pills, a `+` — and then the terminal down to the accessory keys. Press
back: all three come back, the tab is still in the strip and its dot is still green.
**Failure means:** one of the three rows is not bound on `IsShowingPages`, or the terminal is being reached
by a route that leaves `Surface` on `Page`.
### 11.2 The connect menu is not drawn over the renderer · **the important one**
With a shell showing output, press `+`.
**Pass:** the terminal's rectangle goes to the canvas colour and the sheet sits over it whole — scrim, three
rows and CANCEL, every one of them tappable, none of them sliced down the left edge. Tap the scrim: the
terminal comes back with its scrollback intact and the shell still running.
**Failure means:** `IsTerminalShowing` is not being cleared by `IsConnectSheetOpen` — or, if the sheet is
sliced *despite* the rectangle going blank, something else in that Panel is still showing. A sheet that
draws over live terminal output is the Android answer to the compositing question, and it means every
future sheet on this surface has to collapse the renderer too.
### 11.3 Back lowers the menu before it leaves the terminal
With the connect menu open, use the system back gesture.
**Pass:** the menu closes and the terminal is still showing. A second back leaves the terminal for the
screen it was opened over.
**Failure means:** the guard in `PhoneShell.axaml.cs` is below the surface check rather than above it, and
one gesture is spending two levels.
### 11.4 The two end buttons cannot be pushed off the bar
Open six or more shells.
**Pass:** the pills scroll under a fixed back arrow and a fixed `+`; neither ever leaves the screen, and
scrolling the pills to either end does not move them.
**Failure means:** a control was put inside the `ScrollViewer` rather than beside it.
### 11.5 The text-size buttons are always reachable
With a shell open, scroll the accessory key row to the far left and the far right.
**Pass:** `A` and `A+` stay pinned at the right-hand end throughout, separated from the keys by the
hairline, and both are at least 30 tall. At the smallest and largest sizes the one that can do nothing is
visibly disabled rather than silently inert.
**Failure means:** they have been folded into the scrolling row — which is the arrangement the connection
line existed to avoid, and the reason it could be removed at all.
---
## Phase 12 — Teams: the operations that span two accounts
The server's own rules are covered by the endpoint suite: teams are renamed, an archive is refused while a
vault is in the way, ownership changes hands, and every branch of the invitation claim is driven with
tokens the test mints itself. That last freedom is exactly what puts this phase here. **A test can mint a
token asserting anything it likes, so it can prove the server's rule and can say nothing whatever about
whether *your* identity provider sends the claim that rule depends on** — and an invitation that never
activates fails by sitting still, which is the failure mode nobody notices. What is left needs two real
accounts, a real sign-in, and in two cases a clock.
**Two accounts, and two profiles.** The dev realm ships `alice` and `bob`, both with verified addresses; a
second DodoSSH profile means a second machine, a second OS user, or the same machine after signing out.
Whichever account plays the invitee **must not have signed in to this deployment before** — most of what
follows is about what happens the first time it does.
### 12.1 An invitation becomes a membership at the invitee's first sign-in · **the one worth the most care**
1. Sign in as `alice`, make a team, and open its invitations.
2. Invite `bob@example.com` as a Member. **Nothing is sent, and nothing should look as though it was**
no "invitation emailed", no link to copy, no token anywhere on the screen.
3. **Pass:** the row appears as *pending*, carrying the address, the role and an expiry fourteen days out.
Bob is **not** in the members table, because he has no account here for a membership row to point at.
4. Sign in as `bob` on the second profile and enroll.
5. **Pass:** the team is in Bob's list the first time he looks, at Member, with nothing further pressed on
either side. Back on Alice's machine, refresh: the invitation reads *accepted* rather than vanishing,
and Bob is now in the members table.
6. **Pass, and this is the half that is easiest to lose:** the team's vault is in Bob's list **saying it is
waiting for a key**, and nothing in it is readable. Have Alice press SHARE KEY and Bob sync; now it
opens.
**Failure means:** step 5 failing with everything else passing is almost always the `email_verified` claim
— go to 12.2 rather than reading the invitation code, because the server is doing exactly what it should.
Step 6 opening the vault *without* Alice sharing a key would be the far more serious failure: nothing on
the server can wrap a vault key, so an item that decrypts after a membership change alone means a key
reached that machine by a route this architecture says does not exist.
### 12.2 An unverified address claims nothing, and the log is the only place that says so
In Keycloak's admin console, clear **Email verified** on the invitee *before* their first DodoSSH sign-in.
Invite that address, then sign in as them.
**Pass:** they get an account and a personal vault and no team at all. The invitation stays *pending* on
the inviter's screen rather than turning into anything, and the API log carries a warning naming how many
invitations it declined to claim. Now set **Email verified** back on. The claim happens on the next request
that crosses the hourly last-seen window, so it is **not** immediate and restarting the client will not
hurry it along — the account already exists, so there is no second first-sign-in to trigger it.
**Failure means:** if the team appears while the address is unverified, the one security boundary
invitations have is not being enforced, and anybody able to obtain a token asserting a colleague's address
can walk into their team. Stop there. If it stays pending after verifying, the claim is not reaching the
**access** token — check the provider's mappers, and set `Oidc:EmailVerifiedClaim` if it sends the claim
under some other name.
### 12.3 An invitation can be withdrawn until it is taken up
Invite an address, then revoke it before anybody has signed in with it. Then sign in with that address.
**Pass:** the row reads *revoked* and stays on the list rather than disappearing, and the sign-in produces
an ordinary account in no team. Revoking one that has *already* been accepted answers that there was
nothing to withdraw.
**Failure means:** a revoked invitation that still lets somebody in is a removal that did not remove. An
accepted one that could be unpicked here would be worse: it is a membership now, and removing a member
revokes their vault key grants and flags every team vault for rekey, which is not what "revoke invitation"
should quietly do.
### 12.4 An address already in the team is refused; an address that merely has an account is not
With Bob in the team, invite `bob@example.com` to it again.
**Pass:** refused, with a sentence saying the address already belongs to a member and to change their role
instead. Now make a **second** team and invite the same address there.
**Pass:** accepted. Bob having an account is deliberately not a reason to refuse — it is claimed within the
hour on his next request rather than at a sign-in, so give it that long before deciding it has not worked.
**Failure means:** if the second invitation is refused because the address already has an account, this
endpoint has become a way of asking the server which addresses have accounts on it, answerable by anybody
willing to create a team first. See ADR 0009.
### 12.5 LAST ACTIVE is a real time, and a coarse one · **needs a couple of hours**
Use one account and leave the other idle for two or three hours, then read the members table.
**Pass:** the account being used carries a recent time, the idle one does not move, and neither moves more
than once an hour however much is done in it. It is shown as roughly-when, never to the minute.
**Failure means:** a value that tracks every click means the hourly gate is gone and every authenticated
request is writing to `user_account` — which carries the xmin concurrency token, so the next symptom is a
user's own overlapping requests failing on a version that moved under them. A value frozen at enrollment
means the refresh is not running on ordinary requests at all, which is the state that made this column
impossible to offer honestly before.
### 12.6 Ownership changes hands in one act
As the owner, transfer ownership to another active member, then read both rows.
**Pass:** they are Owner and you are **Admin** — not removed, not Member. Your vault key grants are intact
and the team's vaults have not come back flagged for rekey. Then try to transfer to somebody who is not a
member, and to yourself.
**Pass:** both refused, and the message says which.
**Failure means:** two owners, or none, is the state this being a single transaction exists to prevent, and
either one leaves a team that no client can administer back into shape. If your grants were revoked or the
vaults are now flagged for rekey, the transfer is removing the outgoing owner rather than demoting them.
### 12.7 Archiving is refused while the team owns a vault
With a team that owns at least one vault, try to archive it.
**Pass:** refused, and the message counts the vaults in the way and says there is no way to delete a vault
in this product. The team is still in everybody's list afterwards and its vaults still open.
**Failure means:** an archive that succeeded here would have taken those vaults out of the list of
everybody holding a key — including the person who pressed it, quietly, and with nothing in the product
able to put them back.
### 12.8 Archiving an empty team takes its memberships and its invitations with it · **needs two accounts**
Make a team that owns no vaults, add the second account to it, invite a third address, and archive it.
**Pass:** the team is gone from both accounts' lists. Sign in with the invited address afterwards and it
joins nothing. A new team can be created under the archived one's slug.
**Failure means:** the invited address turning up in a team nobody can see is exactly what revoking pending
invitations inside the same transaction exists to prevent, and it would happen weeks later on a sign-in
nobody is watching. Note that taking the freed slug is correct rather than a defect, and is also the reason
an archived team is only restorable by an operator who checks that first.
### 12.9 Renaming a team, and the slug that does not move
Rename a team and change its description.
**Pass:** the new name is on every screen that names the team, on both accounts after a refresh. The slug is
unchanged and there is nowhere to change it. Nothing claims to know *when* it was renamed.
**Failure means:** a rename that moved the slug could take one an archived team is still holding, and that
archived team could then never be brought back. An "edited" timestamp anywhere on the screen is invented
data — `team` has no updated-at column, so there is nothing behind it.