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.
///
///
///
/// Nothing writes this, and nothing in this server can. A membership names an account —
/// team_membership.user_id is not nullable and carries a foreign key — so a row in this
/// state would have to point at somebody who has never signed in. There was a separate invitation
/// record that stood in for exactly that, and it is gone: an address is not a way into a team, and
/// only an account that exists can be added. See docs/adr/0009-team-access-model.md.
///
///
/// Retained because the column exists and holds this value in nobody's database, and because a
/// client must not fail on a value a later server may send.
///
///
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 report an absence to
/// somebody who is standing right there.
///
///
/// This is the only way into a team. There is no invitation and no address-based path — a
/// membership is granted to an account that already exists, named by somebody who can see it. That
/// rules out the shape where a team is joined by whoever turns up holding a token asserting an
/// address, which is the same attack OidcOptions.AllowEmailLinking refuses one door along. The
/// cost is stated rather than hidden: somebody who has never signed in here cannot be added yet, and
/// the refusal says so. See docs/adr/0009-team-access-model.md.
///
///
/// 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 , which is the end of the road rather than a step on
/// it: the remedy is that person signing in once, and it belongs to them rather than to the caller.
///
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);
///
/// 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);
/// Renames a vault.
///
///
/// The one field of a vault a person chose, and the only one that can be changed. A vault's key
/// generation, its owner and its rekey flag are all consequences of something else happening; its name
/// is what somebody typed into a box, and typing the wrong thing into a box is the ordinary mistake this
/// exists to undo.
///
///
/// It is plaintext, as vault names have always been — a person has to be able to choose a vault before
/// anything is decrypted (docs/crypto.md §10). So a rename is visible to the operator, exactly as
/// the original name was, and this changes nothing about what the server can read.
///
///
/// A whole replacement rather than a patch, for the reason is one: there
/// is a single field, so a repeat is the same vault rather than a second edit.
///
///
/// Display name. Required, 1 to 256 characters.
public sealed record UpdateVaultRequest(string Name);
/// 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);
///
/// Moves a vault to a fresh key, wrapped to the caller.
///
///
///
/// The new key is generated by a client that already holds the current one, and arrives sealed to that
/// same client — the server can neither produce it nor tell that it differs from the old one. What the
/// server does is decide the moment it takes effect: the generation advances in one transaction, so
/// there is no instant at which two clients disagree about which generation is current.
///
///
/// Grants for earlier generations are kept, not revoked. Every item still carries the generation
/// it was sealed under, so withdrawing them would make the vault's whole history unreadable to the
/// people who are still in it. The departed member's grants are revoked — that is what
/// RevokeGrantAsync and removal from the team already do — and this is what stops them reading
/// anything written from here on. It does not reach back; see ADR 0001.
///
///
///
/// The generation being created. Must be exactly one past the vault's current one, so two clients
/// rotating at once cannot both believe they succeeded.
///
/// The new vault key, sealed to the caller's own encryption key.
/// Ed25519 signature over the canonical grant tuple.
/// Signing timestamp, part of the signed tuple.
public sealed record RekeyVaultRequest(
uint KeyGeneration,
byte[] WrappedVaultKey,
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.
///
/// One row per holder, including those whose access has been withdrawn. Not one per grant: a rotated
/// vault leaves a member holding one grant per generation, and the row carries the best of them — so
/// below means they have
/// not been wrapped the current key yet, rather than that one of their grants is old.
///
public sealed record VaultGrantsResponse(
Guid VaultId,
uint KeyGeneration,
bool RekeyRequired,
IReadOnlyList Grants);