Merge branch 'main' into the group's move and its deletion question

Main took the group's EDIT and DELETE off the GROUPS heading while this branch
was adding a MOVE beside them, so the conflict was about the same six pixels
from both directions. Main's answer wins outright, and it is the better one for
the reason its own message gives: a button beside a heading has no card under a
pointer to mean, and had to work its subject out from the selection or from the
trail. Moving a group had that problem worst of all — the thing it takes with it
is everything on the shelf, and "which shelf" is not a question a button there
could answer plainly.

So the MOVE button is gone and the menu entry it was drawn beside is the whole
of it. That entry was already in this branch, above the separator DELETE sits
below, and it needed no change: the card menu selects whatever was right-clicked
before it runs anything, which is exactly the aiming a group move wants.

Three things went with the button. ShowsGroupActions, which main deleted because
hiding buttons was all it did, and which this branch had extended to hide them
for the move panel as well. CanMoveGroupTarget, which existed to answer whether
that button was worth drawing — CanMoveSelectedHost stays, because the phone
really does leave the host's MOVE out rather than offer a refusal, and a menu
whose entries came and went would be a menu whose items move. And the two test
assertions that read them, which were describing the button rather than the
behaviour; what they were guarding is that the two panels never share the
moment, and IsConfirmingGroupDeletion says that directly.

The move panel and the deletion question both keep their place under the
heading, which is where the buttons were and is now simply where that section
puts things. They still exclude each other, by disarming rather than by a
visibility flag: MoveGroup clears a pending deletion and DeleteGroup folds the
move panel away.

Manual checks 3.3 was rewritten by main for the menu and by this branch for the
tick, and now says both; 3.3a is new and walks a two-level shelf across a vault
boundary, which is the half of this feature no headless test can watch land.
This commit is contained in:
2026-08-04 17:11:06 +02:00
38 changed files with 3416 additions and 165 deletions
+60 -7
View File
@@ -353,9 +353,10 @@ What a move cannot do is reach a machine that has already synced the host, which
everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go
to" picker on the Keychain screen and cannot be moved yet.
**A group can be moved too, and it takes its contents with it**MOVE beside EDIT and DELETE over the group
cards, or "Move to another vault…" on the card's own menu. That is the desktop only, because the phone draws
groups as headings in the host list and has never had a way to delete or move one. It is the same re-seal
**A group can be moved too, and it takes its contents with it**"Move to another vault…" on the group
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
the desktop. That is the desktop only, because the phone draws groups as headings in the host list and has
never had a way to delete or move one. It is the same re-seal
and tombstone underneath, applied to every item involved: the group, the groups nested inside it, and every
host filed under any of them, each taking a new id in the destination. Moving less than that was never
coherent — the machines and the child groups are items of the vault the group is leaving, so a group that
@@ -374,6 +375,42 @@ Both default to your personal vault and neither moves on its own, because an ite
visible to everybody holding that vault's key. Choosing a vault in the host editor also decides which groups
it can be filed under: a group is an item like any other and lives in exactly one vault.
### Changes that do not wait
A client holds a WebSocket open to the server — `GET /api/v1/events`, subprotocol
`dodossh.events.v1` — and the server sends a line down it whenever something you can read has moved. The
client's answer is the same delta pull it would have run on its timer, only now rather than in up to a
minute. Two things you can see: an edit somebody else makes appears while you are looking at the list, and
a vault shared with you turns up as soon as they share it.
**What is on that socket is a notice, not your data.** A frame says which vault changed and how far its
change log has got, and nothing else: no item, no ciphertext, not even which item it was. That is the
decision the rest of this rests on, and it is deliberate twice over — the server has nothing else it
*could* send, and keeping it that way means there is still exactly one path that applies a change to your
keychain, so the socket can be wrong or absent without anything being applied incorrectly.
**Polling is still there and is still what guarantees a pass.** The minute timer is unchanged. A network
that eats WebSockets, a server with `Events:Enabled` off, an older server, a proxy that will not upgrade, a
notice dropped because your machine was too slow to read it — every one of those leaves you with exactly
what this product did before the socket existed. Nothing is reachable only this way, and nothing is
supposed to become so.
Three limits are worth knowing rather than discovering:
- **One node.** Fan-out is in-process, so a deployment running more than one API replica only pushes for
writes that its own replica handled. The rest arrive on the timer. The seam for a PostgreSQL
`LISTEN`/`NOTIFY` backplane is in place and is not implemented, because an untested backplane would be
worse than a documented gap.
- **The socket does not outlive your access token.** It is closed at the token's expiry and the client
reconnects with a fresh one, which is a gap you will not see. That, plus re-reading your vault list every
few minutes, is what bounds how long a withdrawn grant can keep producing notices — and what it bounds is
*metadata*, because reading a vault needs a key the server has never held.
- **You are told about your own writes.** Your client pushed, so it has already pulled; the extra pass finds
nothing. Notices are coalesced over a quarter of a second so that a burst is one pass rather than a dozen.
The reasoning, including why this is a WebSocket rather than server-sent events and where a shared terminal
session will attach to it, is in [ADR 0012](docs/adr/0012-realtime-push.md).
### The Android head
`src/DodoSSH.Client.Android` is a phone-first head that shares every view model with the desktop one — the
@@ -652,6 +689,20 @@ keychain plus a terminal — and the spike that gates all of it.
directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from
the middle), and a rename is a copy and a delete rather than one atomic operation. Downloads do resume — a
ranged GET is part of the protocol, which is the one place a bucket beats SFTP.
*Realtime done:* a WebSocket the client holds open, over which the server says which vault has moved so a
pull happens now rather than within the minute. What crosses it is a notice and never an item, which is
what keeps one code path applying changes and makes a dropped socket cost latency and nothing else — the
timer is unchanged and is still the guarantee. Two limits are stated rather than implied: fan-out is
in-process, so a multi-replica deployment falls back to the timer for writes another replica handled, and
a socket is closed at its access token's expiry rather than outliving the credential that authorised it.
See [Changes that do not wait](#changes-that-do-not-wait) and
[ADR 0012](docs/adr/0012-realtime-push.md).
It is also the transport a **shared terminal session** will use — one person's shell, watched or driven by
somebody else. Nothing of that exists yet, and ADR 0012 records the one decision made early so it need not
be renegotiated: session data will be binary frames on this same socket, because base64 in a JSON envelope
is the wrong shape for the one payload here that is continuous rather than occasional.
- **M3 — shared vaults**, sharing, ACLs. *Done.* Membership with roles, a public-key directory, the
append-only key log served for clients to verify against, shared vaults, and vault key grants
wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team
@@ -675,10 +726,12 @@ keychain plus a terminal — and the spike that gates all of it.
the rotation is re-sealed as it is pushed, so nothing reaches the server under a superseded key at all.
See [ADR 0010](docs/adr/0010-vault-key-rotation.md).
**A vault shared with you arrives on the next synchronisation pass**, within the minute, with no sign-in
and nothing to press. There is no push channel, so each pass asks the server which vaults this account can
reach before syncing the ones it already knows — which is also how a vault that has been deleted, or one
whose grant was withdrawn, stops being listed.
**A vault shared with you arrives at once**, with no sign-in and nothing to press. Each pass asks the
server which vaults this account can reach before syncing the ones it already knows — which is also how a
vault that has been deleted, or one whose grant was withdrawn, stops being listed — and the server now
says so the moment somebody wraps a key to you rather than leaving it for the next pass. Without a
reachable socket that becomes "within the minute", which is what it always was; see
[Changes that do not wait](#changes-that-do-not-wait).
**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