namespace DodoSSH.Contracts; /// /// A member's role within a team, as it travels on the wire. /// /// /// /// A separate type from DodoSSH.Domain.TeamRole only because both are visible inside the /// server, exactly as GrantPurpose is separate from GrantKind. The numeric values /// must match that enum, and a test pins them: the two are converted by cast, so a renumbering /// here silently promotes or demotes every member on the next deployment. /// /// /// There is no ConnectOnly role, and there will not be one built this way. Connect is a /// user-interface hint rather than a boundary — SSH terminates on the client, so opening a session /// needs the credential's plaintext on that machine, and "may connect but may not read the key" is /// unenforceable in this architecture. See docs/adr/0001-e2ee-trust-model.md. /// /// public enum TeamMemberRole { /// Not a legal value. Unspecified = 0, /// May read the team's vaults and nothing else. Viewer = 10, /// May read and change the team's vaults. Member = 20, /// May also manage members, create vaults, and share vault keys. Admin = 30, /// Sole owner. Everything an admin may do, and cannot be removed while sole. Owner = 40, } /// State of a team membership, as it travels on the wire. /// /// Values match DodoSSH.Domain.MembershipStatus, for the reason /// gives. /// public enum TeamMemberStatus { /// Not a legal value. Unspecified = 0, /// /// Invited but not yet accepted. /// /// /// /// Still nothing writes this, and invitations shipping is the reason rather than an exception to /// 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 has nothing for that row to point at. An /// invitation is therefore its own record against an address /// (), and it becomes a membership at /// the moment an account with that address first signs in. /// /// /// Retained because the column exists and a client must not fail on a value a later server may /// send — a server that grew a second invitation model would use it. /// /// Invited = 1, /// Active member. Active = 2, /// Removed. Retained so audit history stays resolvable to a person. Revoked = 3, } /// State of a vault key grant, as it travels on the wire. /// Values match DodoSSH.Domain.GrantState. public enum VaultGrantState { /// Not a legal value. Unspecified = 0, /// Usable. Active = 1, /// /// The recipient's identity key changed or the vault was rekeyed, so a member holding Share /// must wrap the key afresh before the recipient can read anything again. /// AwaitingRewrap = 2, /// /// Revoked. Blocks future reads only — anything already downloaded is already gone, and the /// remediation for a departed member is rotating the SSH credential itself. See ADR 0001. /// Revoked = 3, } /// A team the caller belongs to. /// The team. /// Display name. /// URL-safe unique identifier. /// Optional description. /// The caller's own role. /// Active members, including the caller. /// Vaults the team owns. /// When the team was created. public sealed record TeamSummary( Guid TeamId, string Name, string Slug, string? Description, TeamMemberRole Role, int MemberCount, int VaultCount, DateTimeOffset CreatedAt); /// A request to create a team. /// /// is chosen by the client for the same reason a vault id is: a request whose /// response was lost can be re-sent verbatim and returns the identical team rather than creating a /// second one under a name the user only meant to type once. /// /// Client-generated UUIDv7. /// Display name. /// /// URL-safe unique identifier, lowercase. Unique across the deployment, so this is the one field a /// create can fail on for a reason the caller cannot see coming. /// /// Optional description. public sealed record CreateTeamRequest( Guid TeamId, string Name, string Slug, string? Description); /// Renames a team, or changes its description. /// /// /// The slug is not here and cannot be changed. It is what a URL, an operator's query and any bookmark /// name, and it is unique only among live teams — so a rename could take a slug an archived /// team is still holding on to, and the archived one could then never be brought back. Renaming the /// display name is the operation people actually want; renaming the identifier is a migration. /// /// /// A whole replacement rather than a patch: both fields are always sent, so clearing a description is /// sending null rather than a distinct verb. There is nowhere to record when a team was last /// renamed — team has no updated-at column — so no client can show "edited", and this contract /// does not pretend one can. /// /// /// Display name. Required. /// Optional description. Null clears it. public sealed record UpdateTeamRequest(string Name, string? Description); /// Hands a team's ownership to another member. /// /// /// Its own operation rather than a role change, because it is two writes that must not be separable: /// the recipient becomes owner and the outgoing owner becomes an admin, in one transaction. Ownership /// is sole, so doing it as two role changes would leave the team either briefly ownerless or briefly /// owned twice, and refuses /// outright for exactly that reason. /// /// /// The outgoing owner is demoted to rather than removed. 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. /// /// /// /// The member to hand it to. Must already be an active member: adding somebody and making them owner /// in one step would let an address typed once take the team. /// public sealed record TransferTeamOwnershipRequest(Guid UserId); /// One member of a team. /// /// Carries no avatar, because no picture is stored anywhere. It does now carry a last-active time — /// see , which names precisely what it measures, because the useful version /// of that column and the misleading one differ only in what the server bothered to write down. /// /// The member. /// Email, for display. /// Display name. /// Role within the team. /// Membership state. /// /// Whether this member has published an identity key. A member who has not cannot be granted a /// vault key at all — there is nothing to wrap one to — so the interface has to be able to say so /// rather than offering a share that would fail. /// /// When the membership became active. /// /// When this account last made an authenticated request, or null if it never has. /// /// It is deliberately coarse. The server records it at most once per account per hour, so a value an /// hour old means "recently" rather than "at that instant" — which is the granularity the question is /// actually asked at, and a far smaller thing to know about a colleague than a per-request timeline /// would be. Displaying it to the minute would be reading precision into it that is not there. /// /// public sealed record TeamMemberSummary( Guid UserId, string? Email, string? DisplayName, TeamMemberRole Role, TeamMemberStatus Status, bool IsEnrolled, DateTimeOffset? JoinedAt, DateTimeOffset? LastActiveAt = null); /// Adds a member to a team. /// /// /// By user id when the caller has one, and the id comes from a directory lookup they have already /// made. That ordering is not incidental: whoever adds a member is usually about to wrap a vault /// key to their public key, and the key they must verify is the one the directory returned. Resolving /// an address server-side when an id was available would put an account resolution the client never /// saw between those two steps. /// /// /// exists because the directory cannot answer for everybody. It returns /// only accounts that have published a key — an entry exists to be wrapped to, and one carrying no key /// is a check callers forget exactly once — so an account between its first sign-in and its enrollment /// is invisible there. It is still an account, and it can still be a member: membership is server-side /// authorization and grants nothing readable, which is why /// exists to say that a member has no key yet. Without this field such a person could not be added at /// all, and a caller reading the directory's silence as "no account here" would invite an address that /// already has one. /// /// /// No key is verified on this path, and none needs to be: nothing is wrapped by adding somebody. The /// key that matters is fetched and checked at share time, from the directory, by the machine holding /// the vault key. /// /// /// /// The account to add, as returned by the directory. defers to /// . /// /// Role to grant. /// /// The address to resolve, used only when is . Matched /// case-insensitively, exactly as the directory matches. An address with no account here is refused /// with so the caller can offer an invitation instead. /// public sealed record AddTeamMemberRequest( Guid UserId, TeamMemberRole Role, string? Email = null); /// Changes a member's role. /// The new role. public sealed record ChangeTeamMemberRoleRequest(TeamMemberRole Role); /// What has become of an invitation. /// /// Derived from the invitation's own timestamps rather than stored, so — unlike every other enum in /// this file — it has no DodoSSH.Domain twin and no numbering to keep in step. That is the /// point of computing it: is a fact about the clock, and a stored state would /// have to be swept by something that remembered to run. /// public enum TeamInvitationState { /// Not a legal value. Unspecified = 0, /// Waiting. It becomes a membership when an account with this address signs in. Pending = 1, /// Taken up. The address signed in and is now a member. Accepted = 2, /// Withdrawn before it was taken up. Revoked = 3, /// Its lifetime ran out. It will not become a membership. Expired = 4, } /// Invites an address that has no account here yet. /// /// /// By email, where is by user id. Adding a member resolves an /// address through the directory first, so the caller sees the public key they are about to wrap a /// vault to. An invitation cannot do that — there may be no account and therefore no key — so it grants /// nothing readable and is never a step towards sharing. /// /// /// An address that already has an account is accepted rather than refused, and only an address /// already belonging to a member of this team is turned away. Refusing on the strength of an account /// existing would make this endpoint an oracle for which addresses have accounts here, answerable by /// anybody willing to create a team first — and it would be answering a question the caller did not /// ask. Whether the account exists changes only how soon the invitation is taken up: an existing one /// picks it up on its next request. /// /// /// There is no token and nothing is sent. This server has no outbound mail path, so the /// invitation is not a link: it is a standing instruction that the next account to sign in with this /// address joins the team. Telling them to sign in is the caller's job, over a channel this server /// does not carry. That also means the address has to be one the identity provider will assert and /// mark verified — an unverified email is refused at claim time, because an invitation that anybody /// could take by naming somebody else's address is a way in. /// /// /// /// Client-generated UUIDv7, for the reason a team id is client-generated: a create whose response was /// lost can be re-sent verbatim rather than leaving two invitations to the same address. /// /// The address to invite. Matched case-insensitively. /// /// Role to grant on arrival. May not be — ownership is sole and is /// handed over deliberately, never conferred by an address signing in. /// public sealed record CreateTeamInvitationRequest( Guid InvitationId, string Email, TeamMemberRole Role); /// One invitation, as the teams interface sees it. /// /// The address is in plaintext here, as it is on . It is readable by /// the team's members, who are the people it concerns; the server stores it in plaintext either way /// and docs/crypto.md §10 already records that membership metadata is not encrypted. /// /// The invitation. /// The address invited. /// The role it will grant. /// What has become of it. /// Who issued it. /// When it was issued. /// /// When it stops being claimable. An invitation that never expired would be a standing offer on an /// address somebody may hand on or lose. /// /// When an account with this address signed in and took it up, if one has. public sealed record TeamInvitationSummary( Guid InvitationId, string Email, TeamMemberRole Role, TeamInvitationState State, Guid InvitedByUserId, DateTimeOffset CreatedAt, DateTimeOffset ExpiresAt, DateTimeOffset? AcceptedAt); /// /// Creates a vault owned by a team, with its key already wrapped to the creator. /// /// /// Shaped like and for the same reasons: the vault key is /// generated on the client and sealed to the creator's own X25519 key, so the server cannot produce /// this and cannot check that contains anything in particular. A vault /// created with no grant would be a container nobody could ever open, so the two arrive together. /// /// The creator's grant carries no key log head, exactly as a personal vault's does not: there is no /// third party whose key could have been substituted. Every other member's grant does carry /// one — see . /// /// /// Client-generated UUIDv7. /// Display name. Plaintext, as all vault names are. /// The vault key sealed to the creator's encryption key. /// Ed25519 signature over the canonical grant tuple. /// Signing timestamp, part of the signed tuple. public sealed record CreateTeamVaultRequest( Guid VaultId, string Name, byte[] WrappedVaultKey, byte[] GrantSignature, DateTimeOffset GrantedAt); /// Issues a vault key grant to another member. /// /// /// The wrap is made by a client that holds the vault key, to a public key it has verified. The /// server stores both the ciphertext and the signature and can check neither — which is the property /// that makes it a zero-knowledge server rather than a key-holding one. /// /// /// is required here and absent for a self-grant. A third party's key could /// have been substituted by the server; recording the log head the granter observed while wrapping /// is what converts that from an undetectable attack into a detectable one. See docs/crypto.md §7.2. /// /// /// Who the key was wrapped to. /// /// The exact identity key it was wrapped to. Stored so a later rotation invalidates this grant /// explicitly rather than leaving a row that no longer opens. /// /// /// The generation wrapped. Rejected when it is not the vault's current one, because a grant for a /// superseded generation opens nothing and would read as corruption at the far end. /// /// The vault key sealed to the recipient. Opaque to the server. /// The key log head the granter observed while wrapping. /// Ed25519 signature over the canonical grant tuple. /// Signing timestamp, part of the signed tuple. public sealed record IssueVaultGrantRequest( Guid RecipientUserId, byte[] RecipientKeyFingerprint, uint KeyGeneration, byte[] WrappedVaultKey, byte[] KeyLogHead, byte[] GrantSignature, DateTimeOffset GrantedAt); /// One vault key grant, as the sharing interface sees it. /// /// The wrapped key itself is deliberately not here. A member reads their own through /// ; this listing exists so somebody holding Share can see /// who has one, and serving every member's sealed key to every member would be a pointless /// widening of what a stolen access token yields. /// /// Who holds it. /// Their email, for display. /// Their display name. /// Generation this grant is for. /// Grant state. /// Who issued it. /// When it was issued. /// When it was revoked, if it was. public sealed record VaultGrantSummary( Guid RecipientUserId, string? Email, string? DisplayName, uint KeyGeneration, VaultGrantState State, Guid GranterUserId, DateTimeOffset CreatedAt, DateTimeOffset? RevokedAt); /// Who can open a vault, and at which generation. /// The vault. /// /// The vault's current generation. A grant listed at anything lower is stale, which is what a client /// compares against rather than inferring from alone. /// /// Whether a membership change has left this vault needing a rekey. /// Every grant, including revoked ones. public sealed record VaultGrantsResponse( Guid VaultId, uint KeyGeneration, bool RekeyRequired, IReadOnlyList Grants);