Public Access
Merge main into the desktop redesign branch
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:
@@ -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
|
||||
|
||||
+12
-2
@@ -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
|
||||
@@ -508,6 +509,14 @@ go at 360dp:
|
||||
drawn, because a modifier that is on and does not look on is how somebody sends `^L` to a database prompt
|
||||
believing they typed an `l`.
|
||||
|
||||
**The surface has since taken the whole screen.** `PhoneShell` collapses the header, the session strip
|
||||
and the bottom bar while a terminal is showing — one binding on `IsShowingPages` each — and the screen
|
||||
draws a 35-pixel bar in their place: back, the session pills, and a `+` raising a sheet with the three
|
||||
connections there are. That sheet is the head's first control that could be drawn over the renderer, so
|
||||
it collapses it rather than covering it, exactly as the desktop's palette does; whether Android's
|
||||
WebView actually composites above Avalonia content is still the unverified question recorded below, and
|
||||
collapsing is correct under either answer.
|
||||
|
||||
All eight are done for the decided scope, and v2 has since gone past it — see the note under step 7.
|
||||
|
||||
What is left, in the order it matters:
|
||||
@@ -519,7 +528,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.
|
||||
|
||||
---
|
||||
|
||||
@@ -21,10 +21,17 @@ 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. |
|
||||
> | `23 ms · fwd 5432` on the terminal's connection line | The account and endpoint actually dialled. There is no RTT measurement in SSH.NET and nothing forwards anything. |
|
||||
> | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwards anything, so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 35-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. |
|
||||
> | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. |
|
||||
> | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. |
|
||||
> | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. |
|
||||
@@ -131,6 +138,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.
|
||||
@@ -320,9 +334,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
|
||||
@@ -331,6 +347,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. |
|
||||
@@ -338,17 +364,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
|
||||
@@ -377,7 +406,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`. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user