Public Access
Let a team change hands, and be joined by somebody with no account yet
M3 built teams and stopped short of the two operations that decide who controls one. Both were written down as refusals rather than omissions: ADR 0009 listed ownership transfer under "deliberately not built", and design-import-gaps said an invitation needed "a token with a lifetime and an outbound mail path". One of those reasons had expired and the other never applied — an invitation does not need a token if it is not a thing anybody presents. Handing a team over is one write. The member you name becomes owner and you become an admin, in a single transaction, because ownership is sole: promoting first leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody left with the authority to finish a transfer that stopped in the middle. That is also why it is not two calls to the role endpoint, which refuses Owner outright. The outgoing owner is demoted 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 asked for, and somebody handing over a team is usually staying in it. It unblocks the thing that was impossible before: an owner can now leave, by handing the team on first. An invitation is a standing instruction rather than a message. This server has no outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row says the next account signing in with that address joins this team at this role, and telling them to sign in is the caller's job over a channel this server does not carry. A link nobody can deliver would be worse than none. It lives in its own table rather than becoming a membership with MembershipStatus.Invited, and that member stays unwritten for the reason it always was: 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. Widening it would make the unique index on (team, user) meaningless, because PostgreSQL counts every NULL as distinct. Verification is the security boundary, and nothing in this server read it before. A claim requires the access token to assert email_verified. An invitation decides what the server will serve, so one claimable by anybody able to obtain a token carrying somebody else's address is a way into a team — which is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it would have been reintroduced by the back door. There is deliberately no setting that relaxes it: a flag that exists is one somebody turns on for the afternoon their provider is misconfigured. Absence is refused rather than trusted, and logged, because a provider that never sends the claim otherwise leaves every invitation pending with nothing anywhere saying why. Claiming happens at just-in-time provisioning and again on an hourly sweep. The sweep is what makes it recoverable rather than one-shot — an invitation issued between an account being created and that person next signing in would otherwise be stranded for ever — and it shares its rate with the last-seen write because both are housekeeping nobody is waiting on. Archiving is refused while a team owns a vault, and that refusal is the end of the road rather than a step on it. A team vault is readable because of membership, so archiving one that still owned vaults would take them away from everybody holding a key, including the caller, quietly and all at once. Nothing in this product deletes a vault, so no order of operations gets past it today — which is stated with a count of what is in the way, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. It is owner-only, as handing over is; renaming is not, because a rename is visible to everybody and reversible by anybody who can do it. The slug is not renameable at all: it is unique only among live teams, so a rename could take one an archived team is still holding, and that team could then never be restored. LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is refreshed on ordinary authenticated requests, at most once per account per hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency token, so a read-then-write on the hot path would start losing races between one user's own overlapping requests. An hour is the granularity the question is actually asked at, and the interface draws it to the day rather than the minute so it does not read as a precision that is not there. The remarks in Contracts and in the view model that argued at length for the column's absence are rewritten rather than extended; both had become false. Two endpoints already existed and nothing called them. ChangeTeamMemberRole and ListVaultGrants have been reachable since M3. The role picker refuses Owner itself rather than letting the server do it, since the interface already knew the rule; the key-holder list sits under the vault rather than beside the member, because a grant is per vault and a count on a member row would imply per-item sharing, which is M5. It lists withdrawn and stale grants and says which they are — a list that dropped them would show a departed colleague as merely absent rather than as somebody whose key was taken away — and staleness is decided by comparing generations, since a grant can be Active and still open nothing. ADD MEMBER stopped being a dead end. An address the directory did not know used to end at a sentence telling the user their colleague had to sign in first. It invites them instead, from the same button, because which of the two applies is a fact about the server's account table rather than about what the user is doing; which one happened is reported afterwards, because that decides what they do next. An address that merely has an account is invited rather than refused: refusing would have made the endpoint an oracle for which addresses have accounts here, answerable by anybody willing to create a team first. The phone has a TEAMS screen, behind MORE, and it is the reverse of every other row in design-import-gaps: a shipped screen the design had no slot for. It is there because an invitation is claimed by signing in, so somebody told they are now in a team is at least as likely to be holding a phone — and a membership visible only on a head they never installed is one they cannot see. It draws SHARE KEY and nothing that takes something away: wrapping a key is the one act on that screen a server cannot perform at all, and the desktop guards its revocations with a tooltip, which is a control a touch screen cannot show. Two defects were found by an adversarial pass and both were green against the whole suite at the time. The owner-only check on archiving and handing over had been weakened to the admin check while their messages and comments still said owner — and since nothing behind the archive endpoint re-checks it, an admin the owner had promoted could have archived the team out from under them. And the rename endpoint built its response with a hardcoded Owner role, so an admin who renamed a team was handed a summary claiming they owned it, and a client trusting that instead of re-listing would have offered them the two owner-only buttons the server then refuses. The new table gets its constraints tested rather than merely migrated: live uniqueness per (team, address), the citext proof that an address typed by a person matches one cased by a provider, and reissue after both revocation and acceptance. The teams screen gets its first entries in the layout suite, at the minimum window with every list populated and with each of the two states that cover half of it — it had none, and it just grew four sections and a second line in the member row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,10 +43,15 @@ namespace DodoSSH.Contracts;
|
||||
[JsonSerializable(typeof(TeamSummary))]
|
||||
[JsonSerializable(typeof(IReadOnlyList<TeamSummary>))]
|
||||
[JsonSerializable(typeof(CreateTeamRequest))]
|
||||
[JsonSerializable(typeof(UpdateTeamRequest))]
|
||||
[JsonSerializable(typeof(TransferTeamOwnershipRequest))]
|
||||
[JsonSerializable(typeof(TeamMemberSummary))]
|
||||
[JsonSerializable(typeof(IReadOnlyList<TeamMemberSummary>))]
|
||||
[JsonSerializable(typeof(AddTeamMemberRequest))]
|
||||
[JsonSerializable(typeof(ChangeTeamMemberRoleRequest))]
|
||||
[JsonSerializable(typeof(CreateTeamInvitationRequest))]
|
||||
[JsonSerializable(typeof(TeamInvitationSummary))]
|
||||
[JsonSerializable(typeof(IReadOnlyList<TeamInvitationSummary>))]
|
||||
[JsonSerializable(typeof(CreateTeamVaultRequest))]
|
||||
[JsonSerializable(typeof(IssueVaultGrantRequest))]
|
||||
[JsonSerializable(typeof(VaultGrantsResponse))]
|
||||
|
||||
@@ -102,10 +102,34 @@ public static class ProblemCodes
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Refused rather than allowed, because a team with no owner has nobody who can appoint one —
|
||||
/// and the only route back would be an operator editing the database by hand.
|
||||
/// and the only route back would be an operator editing the database by hand. The way past it is
|
||||
/// <c>POST /api/v1/teams/{teamId}/owner</c>, which moves ownership and the outgoing owner's
|
||||
/// demotion in one transaction; a client that gets this code can offer that.
|
||||
/// </remarks>
|
||||
public const string LastTeamOwner = "last-team-owner";
|
||||
|
||||
/// <summary>
|
||||
/// A team cannot be archived while it still owns vaults.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its own code because the remedy is neither "fix what you typed" nor "pick another value": it is
|
||||
/// to deal with the vaults first. Archiving anyway would hide vaults from every member including
|
||||
/// the ones holding keys to them, and this product has no way to delete a vault, so the refusal is
|
||||
/// the honest end of that road rather than a step on it.
|
||||
/// </remarks>
|
||||
public const string TeamNotEmpty = "team-not-empty";
|
||||
|
||||
/// <summary>
|
||||
/// An invitation was rejected: a malformed address, an unknown or ownership role, an expiry the
|
||||
/// server will not issue, or an address that already has an account here.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="InvalidTeam"/> because the most common cause has its own remedy that a
|
||||
/// client can act on — an address that already has an account should be added through the
|
||||
/// directory instead, which is the path that shows the caller the key they are about to trust.
|
||||
/// </remarks>
|
||||
public const string InvalidTeamInvitation = "invalid-team-invitation";
|
||||
|
||||
/// <summary>
|
||||
/// A vault key grant was rejected: a fingerprint or wrap of the wrong size, a generation that is
|
||||
/// not the vault's current one, or a recipient who cannot reach the vault in the first place.
|
||||
|
||||
@@ -9,6 +9,7 @@ const DodoSSH.Contracts.ProblemCodes.InvalidCursor = "invalid-cursor" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.InvalidDeviceRegistration = "invalid-device-registration" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.InvalidEnrollment = "invalid-enrollment" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.InvalidTeam = "invalid-team" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.InvalidTeamInvitation = "invalid-team-invitation" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.InvalidVaultGrant = "invalid-vault-grant" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.LastTeamOwner = "last-team-owner" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.MalformedRequest = "malformed-request" -> string!
|
||||
@@ -16,6 +17,7 @@ const DodoSSH.Contracts.ProblemCodes.PushBatchTooLarge = "push-batch-too-large"
|
||||
const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.RelayTargetRejected = "relay-target-rejected" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.RelayTicketInvalid = "relay-ticket-invalid" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.TeamNotEmpty = "team-not-empty" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.TeamSlugTaken = "team-slug-taken" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.TypeBaseUri = "https://dodossh.dev/problems/" -> string!
|
||||
const DodoSSH.Contracts.ProblemCodes.VaultConflict = "vault-conflict" -> string!
|
||||
@@ -35,6 +37,17 @@ DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Deconstruct(out DodoSSH.Contracts.
|
||||
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? other) -> bool
|
||||
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Role.init -> void
|
||||
DodoSSH.Contracts.CreateTeamInvitationRequest
|
||||
DodoSSH.Contracts.CreateTeamInvitationRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamInvitationRequest!
|
||||
DodoSSH.Contracts.CreateTeamInvitationRequest.CreateTeamInvitationRequest(System.Guid InvitationId, string! Email, DodoSSH.Contracts.TeamMemberRole Role) -> void
|
||||
DodoSSH.Contracts.CreateTeamInvitationRequest.Deconstruct(out System.Guid InvitationId, out string! Email, out DodoSSH.Contracts.TeamMemberRole Role) -> void
|
||||
DodoSSH.Contracts.CreateTeamInvitationRequest.Email.get -> string!
|
||||
DodoSSH.Contracts.CreateTeamInvitationRequest.Email.init -> void
|
||||
DodoSSH.Contracts.CreateTeamInvitationRequest.Equals(DodoSSH.Contracts.CreateTeamInvitationRequest? other) -> bool
|
||||
DodoSSH.Contracts.CreateTeamInvitationRequest.InvitationId.get -> System.Guid
|
||||
DodoSSH.Contracts.CreateTeamInvitationRequest.InvitationId.init -> void
|
||||
DodoSSH.Contracts.CreateTeamInvitationRequest.Role.get -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.CreateTeamInvitationRequest.Role.init -> void
|
||||
DodoSSH.Contracts.CreateTeamRequest
|
||||
DodoSSH.Contracts.CreateTeamRequest.<Clone>$() -> DodoSSH.Contracts.CreateTeamRequest!
|
||||
DodoSSH.Contracts.CreateTeamRequest.CreateTeamRequest(System.Guid TeamId, string! Name, string! Slug, string? Description) -> void
|
||||
@@ -433,12 +446,12 @@ DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.ActivityLogEntry = 12 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.ConnectionLogEntry = 11 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.Credential = 2 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.ObjectStore = 13 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.Host = 1 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.HostCredential = 7 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.HostGroup = 4 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.HostTag = 6 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.KnownHostKey = 10 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.ObjectStore = 13 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.PortForward = 9 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.Snippet = 8 -> DodoSSH.Contracts.SyncEntityType
|
||||
DodoSSH.Contracts.SyncEntityType.SshKey = 3 -> DodoSSH.Contracts.SyncEntityType
|
||||
@@ -554,6 +567,33 @@ DodoSSH.Contracts.SyncPushResult.Status.init -> void
|
||||
DodoSSH.Contracts.SyncPushResult.SyncPushResult(System.Guid OperationId, DodoSSH.Contracts.SyncOperationStatus Status, int? Version, long? ChangeSequence, DodoSSH.Contracts.SyncChange? ServerEntity, string? Detail) -> void
|
||||
DodoSSH.Contracts.SyncPushResult.Version.get -> int?
|
||||
DodoSSH.Contracts.SyncPushResult.Version.init -> void
|
||||
DodoSSH.Contracts.TeamInvitationState
|
||||
DodoSSH.Contracts.TeamInvitationState.Accepted = 2 -> DodoSSH.Contracts.TeamInvitationState
|
||||
DodoSSH.Contracts.TeamInvitationState.Expired = 4 -> DodoSSH.Contracts.TeamInvitationState
|
||||
DodoSSH.Contracts.TeamInvitationState.Pending = 1 -> DodoSSH.Contracts.TeamInvitationState
|
||||
DodoSSH.Contracts.TeamInvitationState.Revoked = 3 -> DodoSSH.Contracts.TeamInvitationState
|
||||
DodoSSH.Contracts.TeamInvitationState.Unspecified = 0 -> DodoSSH.Contracts.TeamInvitationState
|
||||
DodoSSH.Contracts.TeamInvitationSummary
|
||||
DodoSSH.Contracts.TeamInvitationSummary.<Clone>$() -> DodoSSH.Contracts.TeamInvitationSummary!
|
||||
DodoSSH.Contracts.TeamInvitationSummary.AcceptedAt.get -> System.DateTimeOffset?
|
||||
DodoSSH.Contracts.TeamInvitationSummary.AcceptedAt.init -> void
|
||||
DodoSSH.Contracts.TeamInvitationSummary.CreatedAt.get -> System.DateTimeOffset
|
||||
DodoSSH.Contracts.TeamInvitationSummary.CreatedAt.init -> void
|
||||
DodoSSH.Contracts.TeamInvitationSummary.Deconstruct(out System.Guid InvitationId, out string! Email, out DodoSSH.Contracts.TeamMemberRole Role, out DodoSSH.Contracts.TeamInvitationState State, out System.Guid InvitedByUserId, out System.DateTimeOffset CreatedAt, out System.DateTimeOffset ExpiresAt, out System.DateTimeOffset? AcceptedAt) -> void
|
||||
DodoSSH.Contracts.TeamInvitationSummary.Email.get -> string!
|
||||
DodoSSH.Contracts.TeamInvitationSummary.Email.init -> void
|
||||
DodoSSH.Contracts.TeamInvitationSummary.Equals(DodoSSH.Contracts.TeamInvitationSummary? other) -> bool
|
||||
DodoSSH.Contracts.TeamInvitationSummary.ExpiresAt.get -> System.DateTimeOffset
|
||||
DodoSSH.Contracts.TeamInvitationSummary.ExpiresAt.init -> void
|
||||
DodoSSH.Contracts.TeamInvitationSummary.InvitationId.get -> System.Guid
|
||||
DodoSSH.Contracts.TeamInvitationSummary.InvitationId.init -> void
|
||||
DodoSSH.Contracts.TeamInvitationSummary.InvitedByUserId.get -> System.Guid
|
||||
DodoSSH.Contracts.TeamInvitationSummary.InvitedByUserId.init -> void
|
||||
DodoSSH.Contracts.TeamInvitationSummary.Role.get -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.TeamInvitationSummary.Role.init -> void
|
||||
DodoSSH.Contracts.TeamInvitationSummary.State.get -> DodoSSH.Contracts.TeamInvitationState
|
||||
DodoSSH.Contracts.TeamInvitationSummary.State.init -> void
|
||||
DodoSSH.Contracts.TeamInvitationSummary.TeamInvitationSummary(System.Guid InvitationId, string! Email, DodoSSH.Contracts.TeamMemberRole Role, DodoSSH.Contracts.TeamInvitationState State, System.Guid InvitedByUserId, System.DateTimeOffset CreatedAt, System.DateTimeOffset ExpiresAt, System.DateTimeOffset? AcceptedAt) -> void
|
||||
DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.TeamMemberRole.Admin = 30 -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.TeamMemberRole.Member = 20 -> DodoSSH.Contracts.TeamMemberRole
|
||||
@@ -567,7 +607,7 @@ DodoSSH.Contracts.TeamMemberStatus.Revoked = 3 -> DodoSSH.Contracts.TeamMemberSt
|
||||
DodoSSH.Contracts.TeamMemberStatus.Unspecified = 0 -> DodoSSH.Contracts.TeamMemberStatus
|
||||
DodoSSH.Contracts.TeamMemberSummary
|
||||
DodoSSH.Contracts.TeamMemberSummary.<Clone>$() -> DodoSSH.Contracts.TeamMemberSummary!
|
||||
DodoSSH.Contracts.TeamMemberSummary.Deconstruct(out System.Guid UserId, out string? Email, out string? DisplayName, out DodoSSH.Contracts.TeamMemberRole Role, out DodoSSH.Contracts.TeamMemberStatus Status, out bool IsEnrolled, out System.DateTimeOffset? JoinedAt) -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.Deconstruct(out System.Guid UserId, out string? Email, out string? DisplayName, out DodoSSH.Contracts.TeamMemberRole Role, out DodoSSH.Contracts.TeamMemberStatus Status, out bool IsEnrolled, out System.DateTimeOffset? JoinedAt, out System.DateTimeOffset? LastActiveAt) -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.DisplayName.get -> string?
|
||||
DodoSSH.Contracts.TeamMemberSummary.DisplayName.init -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.Email.get -> string?
|
||||
@@ -577,11 +617,13 @@ DodoSSH.Contracts.TeamMemberSummary.IsEnrolled.get -> bool
|
||||
DodoSSH.Contracts.TeamMemberSummary.IsEnrolled.init -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.JoinedAt.get -> System.DateTimeOffset?
|
||||
DodoSSH.Contracts.TeamMemberSummary.JoinedAt.init -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.LastActiveAt.get -> System.DateTimeOffset?
|
||||
DodoSSH.Contracts.TeamMemberSummary.LastActiveAt.init -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.Role.get -> DodoSSH.Contracts.TeamMemberRole
|
||||
DodoSSH.Contracts.TeamMemberSummary.Role.init -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.Status.get -> DodoSSH.Contracts.TeamMemberStatus
|
||||
DodoSSH.Contracts.TeamMemberSummary.Status.init -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.TeamMemberSummary(System.Guid UserId, string? Email, string? DisplayName, DodoSSH.Contracts.TeamMemberRole Role, DodoSSH.Contracts.TeamMemberStatus Status, bool IsEnrolled, System.DateTimeOffset? JoinedAt) -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.TeamMemberSummary(System.Guid UserId, string? Email, string? DisplayName, DodoSSH.Contracts.TeamMemberRole Role, DodoSSH.Contracts.TeamMemberStatus Status, bool IsEnrolled, System.DateTimeOffset? JoinedAt, System.DateTimeOffset? LastActiveAt = null) -> void
|
||||
DodoSSH.Contracts.TeamMemberSummary.UserId.get -> System.Guid
|
||||
DodoSSH.Contracts.TeamMemberSummary.UserId.init -> void
|
||||
DodoSSH.Contracts.TeamSummary
|
||||
@@ -605,6 +647,22 @@ DodoSSH.Contracts.TeamSummary.TeamId.init -> void
|
||||
DodoSSH.Contracts.TeamSummary.TeamSummary(System.Guid TeamId, string! Name, string! Slug, string? Description, DodoSSH.Contracts.TeamMemberRole Role, int MemberCount, int VaultCount, System.DateTimeOffset CreatedAt) -> void
|
||||
DodoSSH.Contracts.TeamSummary.VaultCount.get -> int
|
||||
DodoSSH.Contracts.TeamSummary.VaultCount.init -> void
|
||||
DodoSSH.Contracts.TransferTeamOwnershipRequest
|
||||
DodoSSH.Contracts.TransferTeamOwnershipRequest.<Clone>$() -> DodoSSH.Contracts.TransferTeamOwnershipRequest!
|
||||
DodoSSH.Contracts.TransferTeamOwnershipRequest.Deconstruct(out System.Guid UserId) -> void
|
||||
DodoSSH.Contracts.TransferTeamOwnershipRequest.Equals(DodoSSH.Contracts.TransferTeamOwnershipRequest? other) -> bool
|
||||
DodoSSH.Contracts.TransferTeamOwnershipRequest.TransferTeamOwnershipRequest(System.Guid UserId) -> void
|
||||
DodoSSH.Contracts.TransferTeamOwnershipRequest.UserId.get -> System.Guid
|
||||
DodoSSH.Contracts.TransferTeamOwnershipRequest.UserId.init -> void
|
||||
DodoSSH.Contracts.UpdateTeamRequest
|
||||
DodoSSH.Contracts.UpdateTeamRequest.<Clone>$() -> DodoSSH.Contracts.UpdateTeamRequest!
|
||||
DodoSSH.Contracts.UpdateTeamRequest.Deconstruct(out string! Name, out string? Description) -> void
|
||||
DodoSSH.Contracts.UpdateTeamRequest.Description.get -> string?
|
||||
DodoSSH.Contracts.UpdateTeamRequest.Description.init -> void
|
||||
DodoSSH.Contracts.UpdateTeamRequest.Equals(DodoSSH.Contracts.UpdateTeamRequest? other) -> bool
|
||||
DodoSSH.Contracts.UpdateTeamRequest.Name.get -> string!
|
||||
DodoSSH.Contracts.UpdateTeamRequest.Name.init -> void
|
||||
DodoSSH.Contracts.UpdateTeamRequest.UpdateTeamRequest(string! Name, string? Description) -> void
|
||||
DodoSSH.Contracts.VaultGrantsResponse
|
||||
DodoSSH.Contracts.VaultGrantsResponse.<Clone>$() -> DodoSSH.Contracts.VaultGrantsResponse!
|
||||
DodoSSH.Contracts.VaultGrantsResponse.Deconstruct(out System.Guid VaultId, out uint KeyGeneration, out bool RekeyRequired, out System.Collections.Generic.IReadOnlyList<DodoSSH.Contracts.VaultGrantSummary!>! Grants) -> void
|
||||
@@ -671,6 +729,9 @@ override DodoSSH.Contracts.AddTeamMemberRequest.ToString() -> string!
|
||||
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.ChangeTeamMemberRoleRequest.ToString() -> string!
|
||||
override DodoSSH.Contracts.CreateTeamInvitationRequest.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.CreateTeamInvitationRequest.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.CreateTeamInvitationRequest.ToString() -> string!
|
||||
override DodoSSH.Contracts.CreateTeamRequest.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.CreateTeamRequest.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.CreateTeamRequest.ToString() -> string!
|
||||
@@ -761,12 +822,21 @@ override DodoSSH.Contracts.SyncPushResponse.ToString() -> string!
|
||||
override DodoSSH.Contracts.SyncPushResult.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.SyncPushResult.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.SyncPushResult.ToString() -> string!
|
||||
override DodoSSH.Contracts.TeamInvitationSummary.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.TeamInvitationSummary.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.TeamInvitationSummary.ToString() -> string!
|
||||
override DodoSSH.Contracts.TeamMemberSummary.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.TeamMemberSummary.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.TeamMemberSummary.ToString() -> string!
|
||||
override DodoSSH.Contracts.TeamSummary.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.TeamSummary.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.TeamSummary.ToString() -> string!
|
||||
override DodoSSH.Contracts.TransferTeamOwnershipRequest.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.TransferTeamOwnershipRequest.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.TransferTeamOwnershipRequest.ToString() -> string!
|
||||
override DodoSSH.Contracts.UpdateTeamRequest.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.UpdateTeamRequest.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.UpdateTeamRequest.ToString() -> string!
|
||||
override DodoSSH.Contracts.VaultGrantsResponse.Equals(object? obj) -> bool
|
||||
override DodoSSH.Contracts.VaultGrantsResponse.GetHashCode() -> int
|
||||
override DodoSSH.Contracts.VaultGrantsResponse.ToString() -> string!
|
||||
@@ -780,6 +850,8 @@ static DodoSSH.Contracts.AddTeamMemberRequest.operator !=(DodoSSH.Contracts.AddT
|
||||
static DodoSSH.Contracts.AddTeamMemberRequest.operator ==(DodoSSH.Contracts.AddTeamMemberRequest? left, DodoSSH.Contracts.AddTeamMemberRequest? right) -> bool
|
||||
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator !=(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
|
||||
static DodoSSH.Contracts.ChangeTeamMemberRoleRequest.operator ==(DodoSSH.Contracts.ChangeTeamMemberRoleRequest? left, DodoSSH.Contracts.ChangeTeamMemberRoleRequest? right) -> bool
|
||||
static DodoSSH.Contracts.CreateTeamInvitationRequest.operator !=(DodoSSH.Contracts.CreateTeamInvitationRequest? left, DodoSSH.Contracts.CreateTeamInvitationRequest? right) -> bool
|
||||
static DodoSSH.Contracts.CreateTeamInvitationRequest.operator ==(DodoSSH.Contracts.CreateTeamInvitationRequest? left, DodoSSH.Contracts.CreateTeamInvitationRequest? right) -> bool
|
||||
static DodoSSH.Contracts.CreateTeamRequest.operator !=(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
|
||||
static DodoSSH.Contracts.CreateTeamRequest.operator ==(DodoSSH.Contracts.CreateTeamRequest? left, DodoSSH.Contracts.CreateTeamRequest? right) -> bool
|
||||
static DodoSSH.Contracts.CreateTeamVaultRequest.operator !=(DodoSSH.Contracts.CreateTeamVaultRequest? left, DodoSSH.Contracts.CreateTeamVaultRequest? right) -> bool
|
||||
@@ -843,10 +915,16 @@ static DodoSSH.Contracts.SyncPushResponse.operator !=(DodoSSH.Contracts.SyncPush
|
||||
static DodoSSH.Contracts.SyncPushResponse.operator ==(DodoSSH.Contracts.SyncPushResponse? left, DodoSSH.Contracts.SyncPushResponse? right) -> bool
|
||||
static DodoSSH.Contracts.SyncPushResult.operator !=(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
|
||||
static DodoSSH.Contracts.SyncPushResult.operator ==(DodoSSH.Contracts.SyncPushResult? left, DodoSSH.Contracts.SyncPushResult? right) -> bool
|
||||
static DodoSSH.Contracts.TeamInvitationSummary.operator !=(DodoSSH.Contracts.TeamInvitationSummary? left, DodoSSH.Contracts.TeamInvitationSummary? right) -> bool
|
||||
static DodoSSH.Contracts.TeamInvitationSummary.operator ==(DodoSSH.Contracts.TeamInvitationSummary? left, DodoSSH.Contracts.TeamInvitationSummary? right) -> bool
|
||||
static DodoSSH.Contracts.TeamMemberSummary.operator !=(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
|
||||
static DodoSSH.Contracts.TeamMemberSummary.operator ==(DodoSSH.Contracts.TeamMemberSummary? left, DodoSSH.Contracts.TeamMemberSummary? right) -> bool
|
||||
static DodoSSH.Contracts.TeamSummary.operator !=(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool
|
||||
static DodoSSH.Contracts.TeamSummary.operator ==(DodoSSH.Contracts.TeamSummary? left, DodoSSH.Contracts.TeamSummary? right) -> bool
|
||||
static DodoSSH.Contracts.TransferTeamOwnershipRequest.operator !=(DodoSSH.Contracts.TransferTeamOwnershipRequest? left, DodoSSH.Contracts.TransferTeamOwnershipRequest? right) -> bool
|
||||
static DodoSSH.Contracts.TransferTeamOwnershipRequest.operator ==(DodoSSH.Contracts.TransferTeamOwnershipRequest? left, DodoSSH.Contracts.TransferTeamOwnershipRequest? right) -> bool
|
||||
static DodoSSH.Contracts.UpdateTeamRequest.operator !=(DodoSSH.Contracts.UpdateTeamRequest? left, DodoSSH.Contracts.UpdateTeamRequest? right) -> bool
|
||||
static DodoSSH.Contracts.UpdateTeamRequest.operator ==(DodoSSH.Contracts.UpdateTeamRequest? left, DodoSSH.Contracts.UpdateTeamRequest? right) -> bool
|
||||
static DodoSSH.Contracts.VaultGrantsResponse.operator !=(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
|
||||
static DodoSSH.Contracts.VaultGrantsResponse.operator ==(DodoSSH.Contracts.VaultGrantsResponse? left, DodoSSH.Contracts.VaultGrantsResponse? right) -> bool
|
||||
static DodoSSH.Contracts.VaultGrantSummary.operator !=(DodoSSH.Contracts.VaultGrantSummary? left, DodoSSH.Contracts.VaultGrantSummary? right) -> bool
|
||||
|
||||
@@ -49,10 +49,18 @@ public enum TeamMemberStatus
|
||||
/// Invited but not yet accepted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing writes this today. An invitation needs a token with a lifetime and an outbound mail
|
||||
/// path, and this server has neither — so a member is added by looking their account up in the
|
||||
/// directory, which requires that they have signed in here at least once. Retained because the
|
||||
/// column exists and a client must not fail on a value a later server may send.
|
||||
/// <para>
|
||||
/// Still nothing writes this, and invitations shipping is the reason rather than an exception to
|
||||
/// it. A membership names an account: <c>team_membership.user_id</c> 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 <em>address</em>
|
||||
/// (<see cref="TeamInvitationSummary"/>), and it becomes a membership at
|
||||
/// <see cref="Active"/> the moment an account with that address first signs in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
Invited = 1,
|
||||
|
||||
@@ -124,11 +132,51 @@ public sealed record CreateTeamRequest(
|
||||
string Slug,
|
||||
string? Description);
|
||||
|
||||
/// <summary>Renames a team, or changes its description.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <em>live</em> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// 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 <em>when</em> a team was last
|
||||
/// renamed — <c>team</c> has no updated-at column — so no client can show "edited", and this contract
|
||||
/// does not pretend one can.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Name">Display name. Required.</param>
|
||||
/// <param name="Description">Optional description. Null clears it.</param>
|
||||
public sealed record UpdateTeamRequest(string Name, string? Description);
|
||||
|
||||
/// <summary>Hands a team's ownership to another member.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// 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 <see cref="ChangeTeamMemberRoleRequest"/> refuses
|
||||
/// <see cref="TeamMemberRole.Owner"/> outright for exactly that reason.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The outgoing owner is demoted to <see cref="TeamMemberRole.Admin"/> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="UserId">
|
||||
/// 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.
|
||||
/// </param>
|
||||
public sealed record TransferTeamOwnershipRequest(Guid UserId);
|
||||
|
||||
/// <summary>One member of a team.</summary>
|
||||
/// <remarks>
|
||||
/// Carries no last-active time and no avatar. <c>UserAccount.LastSeenAtUtc</c> is written at
|
||||
/// provisioning and at enrollment and at no other point, so a column labelled "last active" would
|
||||
/// be reporting something else entirely; and no picture is stored anywhere.
|
||||
/// Carries no avatar, because no picture is stored anywhere. It does now carry a last-active time —
|
||||
/// see <see cref="LastActiveAt"/>, 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.
|
||||
/// </remarks>
|
||||
/// <param name="UserId">The member.</param>
|
||||
/// <param name="Email">Email, for display.</param>
|
||||
@@ -141,6 +189,15 @@ public sealed record CreateTeamRequest(
|
||||
/// rather than offering a share that would fail.
|
||||
/// </param>
|
||||
/// <param name="JoinedAt">When the membership became active.</param>
|
||||
/// <param name="LastActiveAt">
|
||||
/// When this account last made an authenticated request, or null if it never has.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </param>
|
||||
public sealed record TeamMemberSummary(
|
||||
Guid UserId,
|
||||
string? Email,
|
||||
@@ -148,7 +205,8 @@ public sealed record TeamMemberSummary(
|
||||
TeamMemberRole Role,
|
||||
TeamMemberStatus Status,
|
||||
bool IsEnrolled,
|
||||
DateTimeOffset? JoinedAt);
|
||||
DateTimeOffset? JoinedAt,
|
||||
DateTimeOffset? LastActiveAt = null);
|
||||
|
||||
/// <summary>Adds a member to a team.</summary>
|
||||
/// <remarks>
|
||||
@@ -165,6 +223,97 @@ public sealed record AddTeamMemberRequest(Guid UserId, TeamMemberRole Role);
|
||||
/// <param name="Role">The new role.</param>
|
||||
public sealed record ChangeTeamMemberRoleRequest(TeamMemberRole Role);
|
||||
|
||||
/// <summary>What has become of an invitation.</summary>
|
||||
/// <remarks>
|
||||
/// Derived from the invitation's own timestamps rather than stored, so — unlike every other enum in
|
||||
/// this file — it has no <c>DodoSSH.Domain</c> twin and no numbering to keep in step. That is the
|
||||
/// point of computing it: <see cref="Expired"/> is a fact about the clock, and a stored state would
|
||||
/// have to be swept by something that remembered to run.
|
||||
/// </remarks>
|
||||
public enum TeamInvitationState
|
||||
{
|
||||
/// <summary>Not a legal value.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>Waiting. It becomes a membership when an account with this address signs in.</summary>
|
||||
Pending = 1,
|
||||
|
||||
/// <summary>Taken up. The address signed in and is now a member.</summary>
|
||||
Accepted = 2,
|
||||
|
||||
/// <summary>Withdrawn before it was taken up.</summary>
|
||||
Revoked = 3,
|
||||
|
||||
/// <summary>Its lifetime ran out. It will not become a membership.</summary>
|
||||
Expired = 4,
|
||||
}
|
||||
|
||||
/// <summary>Invites an address that has no account here yet.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>By email, where <see cref="AddTeamMemberRequest"/> is by user id.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>An address that already has an account is accepted rather than refused</b>, 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>There is no token and nothing is sent.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="InvitationId">
|
||||
/// 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.
|
||||
/// </param>
|
||||
/// <param name="Email">The address to invite. Matched case-insensitively.</param>
|
||||
/// <param name="Role">
|
||||
/// Role to grant on arrival. May not be <see cref="TeamMemberRole.Owner"/> — ownership is sole and is
|
||||
/// handed over deliberately, never conferred by an address signing in.
|
||||
/// </param>
|
||||
public sealed record CreateTeamInvitationRequest(
|
||||
Guid InvitationId,
|
||||
string Email,
|
||||
TeamMemberRole Role);
|
||||
|
||||
/// <summary>One invitation, as the teams interface sees it.</summary>
|
||||
/// <remarks>
|
||||
/// The address is in plaintext here, as it is on <see cref="TeamMemberSummary"/>. 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.
|
||||
/// </remarks>
|
||||
/// <param name="InvitationId">The invitation.</param>
|
||||
/// <param name="Email">The address invited.</param>
|
||||
/// <param name="Role">The role it will grant.</param>
|
||||
/// <param name="State">What has become of it.</param>
|
||||
/// <param name="InvitedByUserId">Who issued it.</param>
|
||||
/// <param name="CreatedAt">When it was issued.</param>
|
||||
/// <param name="ExpiresAt">
|
||||
/// When it stops being claimable. An invitation that never expired would be a standing offer on an
|
||||
/// address somebody may hand on or lose.
|
||||
/// </param>
|
||||
/// <param name="AcceptedAt">When an account with this address signed in and took it up, if one has.</param>
|
||||
public sealed record TeamInvitationSummary(
|
||||
Guid InvitationId,
|
||||
string Email,
|
||||
TeamMemberRole Role,
|
||||
TeamInvitationState State,
|
||||
Guid InvitedByUserId,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset ExpiresAt,
|
||||
DateTimeOffset? AcceptedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a vault owned by a team, with its key already wrapped to the creator.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user