Public Access
Merge main into the phone connections branch
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:
@@ -33,7 +33,7 @@ existed since the first migration — but nothing had had to name the split.
|
||||
else. Whether the caller can read what it serves is decided by whether they hold a grant, which the
|
||||
server records, cannot produce and cannot verify.
|
||||
|
||||
Four consequences, each of which is a place where a more reassuring design was rejected:
|
||||
Five consequences, each of which is a place where a more reassuring design was rejected:
|
||||
|
||||
- **A member with no grant is a normal state, not an error.** `VaultSummary.WrappedVaultKey` is null
|
||||
and the vault appears in their list saying it is waiting for a key. Hiding it until a grant existed
|
||||
@@ -51,14 +51,21 @@ Four consequences, each of which is a place where a more reassuring design was r
|
||||
- **Removal is named for what it does.** It revokes grants and flags the vault for rekey. It does not
|
||||
claim to reach anything already downloaded, and the interface says the remediation is rotating the
|
||||
credential — the same non-retroactive limit ADR 0001 records.
|
||||
- **Ownership is sole, so handing it over is one write and not a role change.** If membership
|
||||
authorises, the owner's membership is the last authority in the team, and a transfer that stopped
|
||||
halfway would leave nobody with the standing to finish it — owned twice if the promotion went first,
|
||||
owned by nobody if the demotion did, and in either case recoverable only by an operator editing the
|
||||
database. So `POST /teams/{id}/owner` promotes the recipient and demotes the outgoing owner to
|
||||
**admin** in one transaction, `ChangeRoleAsync` refuses `Owner` outright, and the recipient must
|
||||
already be an active member — handing a team to an id supplied once is the same mistake as adding
|
||||
somebody straight to the owner role. Demoting rather than removing is the deliberate half: removing
|
||||
them would revoke their vault key grants and flag every team vault for rekey, which is a far larger
|
||||
act than the one being asked for, and somebody handing over a team is usually staying in it.
|
||||
|
||||
Two things were deliberately **not** built, and both are refusals rather than omissions:
|
||||
One thing is deliberately **not** built, and it is a refusal rather than an omission:
|
||||
|
||||
- **The rekey itself.** Only a client holding the current vault key can re-wrap every item's data key
|
||||
under a new one. The server records that a rotation is owed and the interface reports it. M5.
|
||||
- **Ownership transfer.** The owner cannot be demoted or removed, with its own problem code. Allowing
|
||||
it without a transfer would leave a team nobody can administer, recoverable only by an operator
|
||||
editing the database.
|
||||
|
||||
Two smaller choices, recorded because the alternative was written down first and rejected:
|
||||
|
||||
@@ -74,11 +81,47 @@ Two smaller choices, recorded because the alternative was written down first and
|
||||
until that exists, the safe direction is the narrow one, and the cost — approving a team host's key
|
||||
once per member per machine — is stated in the README rather than hidden.
|
||||
|
||||
### An invitation is membership decided before there is an account to hold it
|
||||
|
||||
A membership names an account: `team_membership.user_id` is not nullable and carries a foreign key, so
|
||||
somebody who has never signed in here has nothing for that row to point at. `MembershipStatus.Invited`
|
||||
has existed since the first migration and is still never written — not as an oversight, but because a
|
||||
membership waiting for a person is the one shape this model cannot store. An invitation is therefore its
|
||||
own record, `team_invitation`, held against an **address**, and it becomes an ordinary active membership
|
||||
the moment an account with that address signs in.
|
||||
|
||||
That extends the model rather than bending it. An invitation grants nothing readable and cannot be a
|
||||
step towards sharing, because there is no account and so no public key to wrap a vault to. It moves the
|
||||
first half of the split earlier and leaves the second half exactly where it was.
|
||||
|
||||
Three decisions inside it belong here, because each had a more convenient alternative:
|
||||
|
||||
- **The claim requires `email_verified` on the access token, and nothing relaxes it.** This is the whole
|
||||
of the security boundary. Membership is authorisation, so an invitation that could be taken by anybody
|
||||
able to obtain a token asserting somebody else's address is a way into a team — the same attack
|
||||
`OidcOptions.AllowEmailLinking` exists to refuse, arriving by another door and deserving the same bar.
|
||||
An unverified or absent claim claims nothing and logs a warning, which is the only signal an operator
|
||||
gets that their provider is not sending it. There is deliberately no setting to trust an unverified
|
||||
address: a flag that exists is a flag somebody turns on for the afternoon their provider is
|
||||
misconfigured, and this is the one it must not be possible to turn on.
|
||||
- **Nothing is sent, and the product says so rather than implying a mail path it has not got.** There is
|
||||
no token and no link — the row is a standing instruction, and telling the invitee to go and sign in
|
||||
happens over a channel this server does not carry. A link nobody can deliver would be worse than none.
|
||||
The compensation, such as it is, is real: an invitation that is not a bearer credential is one that
|
||||
cannot be forwarded, intercepted or replayed.
|
||||
- **An address that already has an account here is accepted rather than refused.** Refusing and pointing
|
||||
at the directory would have been tidier, and would have turned the endpoint into an oracle for which
|
||||
addresses have accounts on this deployment, answerable by anybody willing to create a team first. Only
|
||||
an address already belonging to a member of *this* team is refused, and that is a fact the caller can
|
||||
already read off the members table, so naming it leaks nothing.
|
||||
|
||||
## Consequences
|
||||
|
||||
The sharing graph is visible to the operator: who is in which team, which vaults exist, and who holds
|
||||
a grant are all plaintext rows. That was already true of metadata generally (`docs/crypto.md` §10)
|
||||
and is not made worse here, but it is now a graph rather than a list.
|
||||
and is not made worse here, but it is now a graph rather than a list. Invitations widen it by one
|
||||
edge — an address that has been invited is on the graph before its owner has ever been here — which is
|
||||
the same class of fact and worth naming rather than leaving to be noticed.
|
||||
|
||||
A malicious granter can seal garbage. The recipient detects it as a tag failure and the grant's
|
||||
Ed25519 signature names who issued it — detectable and attributable, which is the most that is
|
||||
|
||||
@@ -489,7 +489,8 @@ go at 360dp:
|
||||
|
||||
The nav rail's eight destinations became four. Pins, snippets, logs, import and teams are not built here:
|
||||
import has no meaning under scoped storage, and the other four are list screens whose view models already
|
||||
exist — they are additive rather than structural.
|
||||
exist — they are additive rather than structural. *(Snippets, logs and teams have since been built, behind
|
||||
MORE. Pins and import have not, and import still cannot be.)*
|
||||
|
||||
**Superseded by v2.** A second design — *DodoSSH Android v2* — is what this head now draws, and it took
|
||||
the "additive rather than structural" claim at its word: snippets, logs, SFTP and S3 are built, over the
|
||||
@@ -552,7 +553,8 @@ What is left, in the order it matters:
|
||||
source, which is the decision recorded above and the obvious next piece of work.
|
||||
- **Editors.** There is no host editor and no keychain item editor on the phone, so both are create-on-
|
||||
desktop-and-sync. That is why the v2 design's `+` buttons on HOSTS and on the keychain are not drawn.
|
||||
- **Pins, teams and import**, which v2 does not draw either.
|
||||
- **Pins and import**, which v2 does not draw either. Teams *is* drawn, behind MORE — it was the one of
|
||||
the three whose view model needed nothing new on the phone, because none of that screen is vault content.
|
||||
- **The App Link upgrade**, unchanged from step 5.
|
||||
|
||||
---
|
||||
|
||||
+77
-14
@@ -29,6 +29,13 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> over a view model that already existed, plus preferences. `ShellScreen` gained `More` and `Buckets`;
|
||||
> SFTP and S3 are one screen over one `TransfersViewModel`, differing only in which picker they offer.
|
||||
>
|
||||
> **A sixth is behind MORE that v2 never drew: TEAMS.** It is the reverse case — a shipped screen the
|
||||
> design had no slot for — and it is on the phone for a reason the design could not have anticipated,
|
||||
> because invitations did not exist when it was drawn. An invitation is claimed by *signing in*, and the
|
||||
> person being invited is at least as likely to be holding a phone as sitting at a desktop; a team the
|
||||
> server has just put somebody in, visible only on a head they may not have installed, is a membership
|
||||
> they cannot see. It runs over the same view model the desktop screen drives, like the other four.
|
||||
>
|
||||
> | v2 element | What ships instead |
|
||||
> | --- | --- |
|
||||
> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps TEAMS in its rail. |
|
||||
@@ -77,7 +84,38 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
||||
> | **Split ⌘D** | Still omitted — the renderer stacks panes and shows one; tiling needs a pane geometry it has not got. |
|
||||
> | macOS traffic lights, and `⌘K` | The window's own minimise/maximise/close, and `CTRL K`. Development is Windows-first and the chrome is `BorderOnly` for a documented reason. |
|
||||
> | No status bar | Kept, and cut down to the one thing the titlebar does not now carry: `Vault.Status`, which is the only channel this application has for saying a save failed or a merge picked a winner. The design is a mock-up of a working afternoon and has nowhere to put a sentence like that. |
|
||||
> | The sidebar's five destinations, and a **Team vault** card at its foot | Nine destinations, because Pins, Teams, Import and Preferences are built screens and dropping their entry would strand them. The card is not drawn: it is a second route to a screen already in the list, carrying a seat count nothing here produces. |
|
||||
> | The sidebar's five destinations, and a **Team vault** card at its foot | Seven destinations, because Pins, Teams and Preferences are built screens and dropping their entry would strand them — and two fewer than v2 shipped with, because SFTP and S3 became tabs; see v3 below. The card is not drawn: it is a second route to a screen already in the list, carrying a seat count nothing here produces. |
|
||||
>
|
||||
> ## The desktop's v3
|
||||
>
|
||||
> A third pass, and the smallest of the three: it moves furniture rather than adding screens. Nothing in it
|
||||
> needed a layer below `client-app`, which is why it has no table of its own — there was nothing to omit.
|
||||
>
|
||||
> **The tab strip became the window's, not the terminal's.** Three fixed tabs sit at its head — Vaults,
|
||||
> SFTP, S3 — and terminal tabs follow them. SFTP and S3 left the nav rail to get there, which is the one
|
||||
> semantic change: they are the two destinations you *stay in* while something runs, and a rail entry is
|
||||
> for somewhere you go and come back from. The rail is drawn under Vaults alone, so SFTP, S3 and a terminal
|
||||
> each get the full window width instead of `826`. See `MainWindowViewModel.IsVaultsTab` for why the tab is
|
||||
> a page test rather than a fourth `ShellSurface`.
|
||||
>
|
||||
> **The hosts screen became a grid of cards** — groups above, hosts below — and the 268-pixel host sidebar
|
||||
> went with it. That column was choosing among forty machines *and* editing one of them at two-thirds
|
||||
> width; the grid took the first job at full width and a 304-pixel right-hand drawer took the second. The
|
||||
> drawer collapses when nothing is selected, which is most of the time. Pressing a group card narrows the
|
||||
> grid to that group; `SHOW ALL` is the way back.
|
||||
>
|
||||
> **The type scale went up a point and the text ramp went white.** `#E3E7F4` was a blue-tinted white on
|
||||
> blue-black surfaces, which costs contrast twice — once for being darker than white and once for sharing
|
||||
> a hue with what it is drawn on. Every step of the ramp moved with the top, so the intervals the design
|
||||
> chose are kept. Both are in the shared palette, so the phone has them too.
|
||||
>
|
||||
> | v3 element | What ships instead |
|
||||
> | --- | --- |
|
||||
> | The hosts toolbar's view-mode switch (grid / list / table) | One view. A switch between three layouts where only one is built is two disabled buttons. |
|
||||
> | The hosts toolbar's tag filter, calendar and share control | Omitted. There are no saved filters, nothing in a vault carries a date, and sharing outside a team vault does not exist. Tags are real and are searched by being read off the cards. |
|
||||
> | **Serial** beside **Terminal** in the toolbar | Omitted. Every session here is an SSH channel; a serial transport is not a button, it is a second session kind. |
|
||||
> | The strip's tabs inside the titlebar row, with a hamburger | A row of its own under the titlebar. The titlebar already carries the drag region, the search box, the sync light and three window controls, and the strip scrolls — putting both on one 44-pixel row would make the thing that scrolls fight the thing that drags. |
|
||||
> | A count on the S3 tab | Omitted. The rail entry it replaces carried one; a number on two of five tabs reads as a fact about those two rather than as the tab's own state, and a terminal tab has nothing to count. The count is on the S3 screen. |
|
||||
|
||||
Most of it landed. This file is the rest: every element of that design with nothing behind it, which
|
||||
project each piece would have to land in, and **what the shipped interface does instead**. That last
|
||||
@@ -109,6 +147,13 @@ unused tables bought. See [Teams](#teams). What has *not* changed is the split u
|
||||
decides what it will serve, and only a client can decide who can read it — so "shared with" is two facts on
|
||||
this screen, not one.
|
||||
|
||||
One table did have to be added, and what it shows is the limit of that reservation. An invitation names an
|
||||
**address**, and `team_membership.user_id` is not nullable and carries a foreign key, so somebody who has
|
||||
never signed in has nothing for that row to point at — which is why `MembershipStatus.Invited` has been
|
||||
reserved since the first migration and is still never written. `team_invitation` is its own table for that
|
||||
one reason. The schema was right about the shape of a team and had said nothing about the shape of joining
|
||||
one.
|
||||
|
||||
**The client has no preferences store.** It writes exactly two files — `cache.db` and `device.key` — and the
|
||||
cache has six tables, none of them settings. Nothing on the design's TERMINAL preferences panel can be
|
||||
saved, and there is no frame on the terminal data plane that would carry a change to the renderer anyway.
|
||||
@@ -228,7 +273,7 @@ caption buttons and window title drawn on top of the application's own — two s
|
||||
| Pane header `aes256-gcm` | client-ssh | **The closest miss on this list.** `SshNetConnection` holds the `SshClient`, so `ConnectionInfo.CurrentServerEncryption` is right there — it just is not on `ISshConnection` or surfaced by `TerminalWorkspace`. | Omitted; the tab strip shows the account and endpoint actually dialled. |
|
||||
| Pane header showing the running command and `following` | client-ssh | The host moves opaque bytes and never parses terminal output. Would need shell integration (OSC 133) on the remote. | Omitted. |
|
||||
| A `local · zsh` tab | client-ssh | Every session here is an SSH channel. Needs ConPTY and a second session kind. | Omitted. |
|
||||
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the sidebar already is. | Omitted. Connect opens a tab; Ctrl+K opens one by name. |
|
||||
| Tab strip `+` button | ui | Not missing so much as redundant: the real operation is *select a host, press Connect*, which the hosts grid already is. | **Shipped**, as the palette rather than a menu: it opens what Ctrl+K opens, so the strip and the shortcut are one way of doing one thing. A `MenuFlyout` offering "SSH" and "local shell" is the nicer answer and is not verifiably safe above the terminal's native child window — and there is no local shell to offer. |
|
||||
| Terminal font size (`--termfs`, 11–16px) | client-storage | See preferences. | Fixed at the renderer's 13px. |
|
||||
|
||||
---
|
||||
@@ -298,9 +343,11 @@ and both editors. What follows is what the design drew around them.
|
||||
|
||||
## Teams
|
||||
|
||||
**Built in M3.** The screen ships: a team list, a members table, the team's vaults, and the two buttons the
|
||||
whole design was really about — add a member, and share a vault key. What follows is what it still does not
|
||||
do, and one thing this document got wrong before it was built.
|
||||
**Built in M3.** The screen ships: a team list, a members table with a real last-active column, the
|
||||
invitations standing against addresses that have no account here yet, the team's vaults, and the two buttons
|
||||
the whole design was really about — add a member, and share a vault key. A team can also be renamed, handed
|
||||
to another member, and archived, the last only while it owns no vaults. What follows is what it still does
|
||||
not do, and one thing this document got wrong before it was built.
|
||||
|
||||
**The correction.** The rows below used to describe a screen with nothing behind it, on the grounds that
|
||||
`VaultAccessService.ResolveAsync` denied every vault that was not the caller's own. That is now the one
|
||||
@@ -309,6 +356,16 @@ place that changed, exactly as its remark predicted, and no migration was needed
|
||||
What the row did not anticipate is that the interesting half is not the endpoints at all. It is that
|
||||
**membership and readability are different things**, and the screen is arranged around saying so.
|
||||
|
||||
**There are two ways into a team and they are not interchangeable.** Adding a member takes a *user id* the
|
||||
caller has already got from the directory, so that account must have signed in here at least once — and the
|
||||
ordering is deliberate rather than incidental, because whoever adds a member is usually about to wrap a
|
||||
vault key to the public key that lookup returned. Inviting takes an *address*, grants nothing readable, and
|
||||
cannot be a step towards sharing: there is no account, so there is no key to wrap to. Inviting an address
|
||||
that already belongs to a member of this team is refused and says so — that is a fact about a team the
|
||||
caller can already see. Inviting one that merely has an account somewhere on this deployment is **not**
|
||||
refused, because answering that would turn the endpoint into an oracle for which addresses have accounts,
|
||||
answerable by anybody willing to create a team first. It simply gets claimed sooner.
|
||||
|
||||
| Design element | Layer | What ships |
|
||||
| --- | --- | --- |
|
||||
| The team itself | server | `POST/GET /api/v1/teams`, plus members, roles and team vaults. Ids are client-chosen, so a create whose response was lost is safe to repeat. |
|
||||
@@ -316,17 +373,20 @@ What the row did not anticipate is that the interesting half is not the endpoint
|
||||
| Roles | contracts + server | `TeamMemberRole` on the wire, numerically pinned to `DodoSSH.Domain.TeamRole` by a test. Viewer reads, Member writes, Admin and Owner also share and administer. |
|
||||
| Members table | server | `TeamMemberSummary`, and a directory that resolves an exact email to a public key. |
|
||||
| Sharing an item | client | `VaultSession.ShareVaultAsync`: verify the recipient's key against the key log, wrap, sign, record. The server stores the wrap and the signature and can check neither. |
|
||||
| Pending invites, and withdrawing one | server | A `team_invitation` row per (team, address), listed beside the members it is about and withdrawable until it is taken up. It becomes a membership when an account with that address signs in — **and only if the access token asserts `email_verified`**, because membership is authorisation and an invitation anybody could take by naming somebody else's address is a way in. Fourteen days, because an address that is reassigned would otherwise carry a standing offer to whoever holds the job next. |
|
||||
| Ownership transfer | server | `POST /api/v1/teams/{id}/owner`, owner only. One transaction: the named member becomes owner and the outgoing owner becomes an admin. Not two role changes — ownership is sole, so promoting first leaves the team owned twice and demoting first leaves it owned by nobody. The outgoing owner is demoted rather than removed, because removing them would revoke their vault key grants and flag every team vault for rekey, which is a far larger act than the one being asked for. |
|
||||
| `LAST ACTIVE` | server | Real, and coarse on purpose. `UserAccount.LastSeenAtUtc` is now refreshed on ordinary authenticated requests, at most once per account per hour: writing it per request would put an UPDATE on the hot path of every authenticated call and start losing races on `user_account`'s own concurrency token. So the column answers "this week or not", which is the granularity the question is actually asked at, and is shown coarsely rather than to the minute. |
|
||||
| Renaming and archiving a team | server | `PUT` and `DELETE /api/v1/teams/{id}`. The slug is deliberately not renameable: it is unique only among *live* teams, so a rename could take a slug an archived team still holds and strand it. Archiving soft-deletes the team, every membership and every pending invitation in one transaction — and is refused outright while the team owns any vault. |
|
||||
|
||||
| Design element | Layer | What it would take | What ships instead |
|
||||
| --- | --- | --- | --- |
|
||||
| `CONNECT-ONLY` role | — | Nothing that would be true. Connect is a user-interface hint, not a boundary: SSH terminates on the client, so a session needs the credential's plaintext on that machine. See ADR 0001. | Four roles, all of which are enforceable. `Connect` rides along with `Read` and is documented as a hint. |
|
||||
| `2FA ENFORCED` and the per-member 2FA column | server | No two-factor concept exists anywhere — the only hit in the whole worktree is an aside in `docs/crypto.md`. | Omitted. The member column carries what *is* known and matters: whether they have published a key a vault can be wrapped to. |
|
||||
| `LAST ACTIVE` | server | `UserAccount.LastSeenAtUtc` is written at just-in-time provisioning and at enrollment and never on an ordinary authenticated request, so the column cannot answer "last active". | Omitted. |
|
||||
| `2FA ENFORCED` and the per-member 2FA column | server | No two-factor concept exists anywhere — the only hit in the whole worktree is an aside in `docs/crypto.md`. | Omitted. The member columns carry what *is* known and matters: whether they have published a key a vault can be wrapped to, and when they were last here. |
|
||||
| Avatars | server | No picture is stored anywhere. | Omitted; the row shows a name and an address. |
|
||||
| Pending invites, resend, revoke | server | An invitation entity, a token with a lifetime, and an outbound mail path. `MembershipStatus.Invited` remains unwritten. | Adding a member resolves an address the caller types against the directory, so the account has to have signed in here once. The screen says that when the lookup finds nothing. |
|
||||
| The invitation mail, and **resend** | server | An outbound mail path: an SMTP configuration, a template, a bounce story and a deliverability problem, none of which this server has. | **Nothing is sent, and the interface says so.** An invitation is a standing instruction rather than a message — the next account to sign in with that address joins the team — so there is no token, no link, and nothing to resend. Telling somebody to sign in is done over a channel this server does not carry. A link nobody can deliver would be worse than no link. |
|
||||
| Archiving a team that owns vaults | — | Nothing that would be safe. A team vault resolves through membership, so archiving would take those vaults away from everybody holding a key, silently, including the caller — and nothing in this product deletes a vault, so there is no sequence of calls that turns the refusal into a success. | Refused, with `team-not-empty` and a count of the vaults in the way. A stated limit rather than a coming feature, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. |
|
||||
| `SSO · OIDC · okta.dodotech.dev` | server | Per-team SSO. Authentication is one global JWT scheme bound to one authority. | Omitted. |
|
||||
| A rekey after a membership change | client | Re-wrapping every item's data key under a fresh vault key, which only a client holding the current one can do. M5. | The vault is flagged `RekeyRequired` and the row says a rotation is owed. |
|
||||
| Ownership transfer | server | A confirmation flow and a rule for what happens to the outgoing owner. | The owner cannot be removed or demoted, with its own problem code rather than a bare 400. |
|
||||
|
||||
> **The trap this document warned about is still a trap.** `GET /api/v1/meta` advertises
|
||||
> `features: ["teams"]` *unconditionally* (`MetaEndpoints.cs`). It was meaningless when nothing implemented
|
||||
@@ -355,7 +415,7 @@ lists the rest as absent rather than omitting it silently.
|
||||
| `GENERAL` section | ui | There is no general setting to put in it. The theme is fixed by decision, and window size is not persisted. |
|
||||
| `KEYS & AGENT` section | client-ssh | **There is no agent, at all** — no own agent, no forwarding, no Pageant or OpenSSH-agent interop. |
|
||||
| `SYNC & VAULT` section | client-domain | Nothing here is adjustable. The auto-sync interval is a `private static readonly` with a remark arguing for its value. |
|
||||
| `SECURITY & SSO` section | server | The SSO half needs team endpoints; there is no policy for the screen to show. |
|
||||
| `SECURITY & SSO` section | server | Per-team SSO, which is a refusal rather than a pending endpoint: authentication is one global JWT scheme bound to one authority, so there is no per-team policy for this screen to show. |
|
||||
| `SHORTCUTS` section | ui | There is no keybinding infrastructure and no rebinding surface. |
|
||||
| Auto-lock after idle | client-session | An activity source, a decision about what counts as idle, and — the hard part — a policy for a shell mid-job. `LockAsync`'s own remark already argues that an unattended timeout which killed a running job would be worse than the exposure it removed. |
|
||||
| Require biometric to sign | crypto | Three separate falsehoods in one row. There is no signing service — the private key is decrypted and handed to SSH.NET whole, so there is no per-signature moment to interrupt; there are no connect-only keys; and per-use consent would need the key to live in the TPM, which is a different key hierarchy from the one in `docs/crypto.md`. |
|
||||
@@ -365,10 +425,13 @@ lists the rest as absent rather than omitting it silently.
|
||||
|
||||
## Two things the import changed on purpose
|
||||
|
||||
**Hosts left the vault column.** They have their own sidebar beside the terminal, and the vault screen
|
||||
holds keys, passwords and pinned host keys. This follows the design, and it is also the better split: the
|
||||
host list is what you look at while you work, and the rest is what you go and manage. `VaultSection` lost
|
||||
its `Hosts` member and gained `All`.
|
||||
**Hosts left the vault column.** They have their own screen and the vault screen holds keys, passwords and
|
||||
pinned host keys. This follows the design, and it is also the better split: the host list is what you look
|
||||
at while you work, and the rest is what you go and manage. `VaultSection` lost its `Hosts` member and
|
||||
gained `All`.
|
||||
|
||||
That screen was a 268-pixel sidebar beside the terminal when this paragraph was written, and v3 made it a
|
||||
grid of cards with a drawer — see above. The split it describes did not change; only which half is wide.
|
||||
|
||||
**Tabs moved to the shell, not the vault.** Locking disposes the vault and deliberately leaves shells
|
||||
running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very
|
||||
|
||||
+172
-20
@@ -22,10 +22,11 @@ Each item says what to do, what a pass looks like, and what a failure would mean
|
||||
|
||||
### 1.1 No screen is sliced at the WebView's left edge · **the important one**
|
||||
|
||||
Open two terminals, then visit every nav rail entry in turn — HOSTS, FILES, KEYS, TEAM, PREFS.
|
||||
Open two terminals, then visit every nav rail entry in turn — Hosts, Keychain, Pins, Snippets, Logs, Teams,
|
||||
Preferences — and both of the fixed tabs, SFTP and S3.
|
||||
|
||||
**Pass:** each screen draws whole, its buttons all clickable, and the tab strip stays across the top of all
|
||||
five.
|
||||
nine. The nav rail is there for the seven and gone for the two, because it belongs to the Vaults tab.
|
||||
|
||||
**Failure means:** a screen is not collapsing while the terminal shows. The terminal is a native child
|
||||
window and composites above everything Avalonia paints, so the symptom is a screen cut off at the WebView's
|
||||
@@ -268,8 +269,8 @@ single-process test can reach.
|
||||
|
||||
Open the hosts screen without creating any group.
|
||||
|
||||
**Pass:** the sidebar list is the flat list of hosts it always was — no headings, no UNGROUPED, nothing
|
||||
saying the hosts are unfiled.
|
||||
**Pass:** the grid is the flat wrap of host cards it always was — no GROUPS section above it, no headings
|
||||
between the cards, no UNGROUPED, nothing saying the hosts are unfiled.
|
||||
|
||||
**Failure means:** the "invisible until used" property is gone, and every existing user gets a heading they
|
||||
did not ask for. `RebuildSidebarRows` returns early when `Groups` is empty; that early return is the feature.
|
||||
@@ -281,9 +282,13 @@ Make two groups, file some hosts into each through the host editor, then click a
|
||||
**Pass:** the heading's chevron flips and its hosts disappear; the count on the heading does not change,
|
||||
because it counts what is in the group rather than what is on screen. Clicking again brings them back.
|
||||
|
||||
**Also check:** clicking a heading does not change which host is selected — the buttons at the foot of the
|
||||
sidebar go on acting on the same machine. This is asserted in a test, but the test drives the view model
|
||||
directly; what it cannot see is whether the `ListBox` writes something else back through the binding first.
|
||||
**Also check:** clicking a heading does not change which host is selected — the drawer stays open on the
|
||||
same machine and its EDIT and DELETE go on acting on it. This is asserted in a test, but the test drives the
|
||||
view model directly; what it cannot see is whether the `ListBox` writes something else back through the
|
||||
binding first.
|
||||
|
||||
**Then press a group card.** The grid narrows to that group's hosts, the card is marked as chosen, and
|
||||
SHOW ALL appears beside GROUPS. Pressing it brings the rest back and unmarks the card.
|
||||
|
||||
### 3.3 Deleting a group with hosts in it
|
||||
|
||||
@@ -639,10 +644,10 @@ back.
|
||||
|
||||
### 7.6 Dragging a host into a group · **least covered, like all drag and drop**
|
||||
|
||||
Make two groups and file a host into one. Drag a host row onto another group's heading; onto a host row
|
||||
Make two groups and file a host into one. Drag a host card onto another group's heading; onto a host card
|
||||
inside another group; and onto UNGROUPED.
|
||||
|
||||
**Pass:** the row under the pointer washes accent while the pointer is over it, the cursor shows a move
|
||||
**Pass:** whatever is under the pointer washes accent while the pointer is over it, the cursor shows a move
|
||||
rather than a refusal, and the drop files the host — it moves under that heading and the counts on both
|
||||
headings change. Dropping onto its own group's heading is refused while still in the air.
|
||||
|
||||
@@ -651,16 +656,19 @@ automated. The write it performs is: `MovingAHostToAGroup_FilesItAndLeavesItSele
|
||||
|
||||
### 7.7 A click still selects, and a double click still connects
|
||||
|
||||
Click host rows; drag one a few pixels without releasing; double-click one.
|
||||
Click host cards; drag one a few pixels without releasing; double-click one. Then double-click a group
|
||||
heading.
|
||||
|
||||
**Pass:** a click selects, a small movement starts nothing, and a double click connects.
|
||||
**Pass:** a click selects, a small movement starts nothing, and a double click connects. Double-clicking a
|
||||
heading folds it and unfolds it again and connects to nothing.
|
||||
|
||||
**Failure means:** the 5-pixel threshold in `HostSidebar.axaml.cs` is not doing its job — the same failure
|
||||
as 2.16 on the other screen, and here it would make the list unusable.
|
||||
**Failure means:** the 5-pixel threshold in `HostsScreen.axaml.cs` is not doing its job — the same failure
|
||||
as 2.16 on the other screen, and here it would make the grid unusable. A heading that connects means the
|
||||
double-tap handler has lost its check that the pointer was over a card.
|
||||
|
||||
### 7.8 The highlight clears after a drag that goes nowhere
|
||||
|
||||
Drag a host over a heading and release outside the list, or press Escape mid-drag.
|
||||
Drag a host over a heading and release outside the grid, or press Escape mid-drag.
|
||||
|
||||
**Pass:** the wash goes away.
|
||||
|
||||
@@ -672,8 +680,8 @@ With host A selected, right-click host B and choose Delete.
|
||||
|
||||
**Pass:** no menu opens at all, and the host selection has not moved.
|
||||
|
||||
**Failure means:** a menu acting on the selection rather than on the row under the pointer deletes the wrong
|
||||
machine. `HostSidebarTests` covers both halves headlessly, so this is a confirmation that a real popup
|
||||
**Failure means:** a menu acting on the selection rather than on the card under the pointer deletes the
|
||||
wrong machine. `HostGridTests` covers both halves headlessly, so this is a confirmation that a real popup
|
||||
behaves as the headless one did.
|
||||
|
||||
### 7.10 Clicking a host in the palette connects
|
||||
@@ -800,11 +808,12 @@ side of it remains unmeasurable for the reasons in phase 8.
|
||||
|
||||
Open a host's editor with a keychain holding a dozen tags.
|
||||
|
||||
**Pass:** the editor pane scrolls, and FORGET HOST KEY is reachable at the bottom of it.
|
||||
**Pass:** the drawer scrolls, and FORGET HOST KEY is reachable at the bottom of it.
|
||||
|
||||
**Failure means:** the pane's MaxHeight is gone or the ScrollViewer is. The layout harness skips anything
|
||||
inside a ScrollViewer, so from that commit on it certifies the pane fits rather than the fields — it will
|
||||
tell you the pane is fine while the last button sits below the window.
|
||||
**Failure means:** the drawer's ScrollViewer is gone. The layout harness skips anything inside one, so from
|
||||
that commit on it certifies the drawer fits rather than the fields — it will tell you the drawer is fine
|
||||
while the last button sits below the window. The editor used to carry a MaxHeight of its own because the
|
||||
host list shared its column; the drawer is alone in its column now, so the height is the window's.
|
||||
|
||||
### 9.2 A chip toggles and reads as toggled
|
||||
|
||||
@@ -1003,3 +1012,146 @@ by gesture or by the arrow, returns to Settings and not to HOSTS; a second back
|
||||
|
||||
**Failure means:** `ShellScreen.Vault` is missing from `IsMoreSurface` or from the back gesture's first
|
||||
case, and those two have to move together — the switch mirrors that property by construction.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
Reference in New Issue
Block a user