Files
DodoSSH/src/DodoSSH.Client.Api/DodoSshApiClient.cs
T
jaap-jan e9cea2ccbc Let a shared vault arrive, a bucket be found, and a vault be deleted
Three things a user reported, one of which was a real bug and one of which was
not the bug it looked like.

**A vault shared with somebody never reached their machine.** The grant was
correct at both ends: the sharing client verified the recipient's key against the
key log and wrapped every generation to it, the server stored it, and /me would
have returned it. Nothing asked. VaultSession.RefreshVaultsAsync — the method
whose own summary says it is "called after a share and on a periodic pass" — had
no caller anywhere in the application, so the vault list was whatever the last
browser sign-in cached. A restart did not help: an offline unlock reads that same
cache. The vault appeared only if the recipient happened to sign in through the
browser again, which is why this looked like sharing being broken rather than
like a list that was never re-read.

So every synchronisation pass now re-reads it, before it syncs. SyncOnceAsync
takes the whole server rather than its sync half for that reason, and the order
matters: a vault admitted by the refresh is one that same pass then pulls, where
the other order would show a newly shared vault as an empty one until the minute
after. The shell is told only when the set actually changed — it rebuilds the tab
strip's vault menu from the session's list, and doing that on every quiet pass
would rebuild a menu once a minute for nothing.

The test needed the fake server to be able to do something no test here had
needed before: hand this account a vault it did not make. ShareVaultWithMe wraps
a real key to the encryption key this account enrolled, so the keyring opens it
exactly as it opens a real colleague's — a helper that filled the field with
bytes would let a vault appear in the list and never prove it could be read.

**Adding an S3 bucket on the desktop works, and could not be found.** The report
was that it is not possible; driving the real XAML headlessly says otherwise —
Keychain, + BUCKET, and the editor saves. What is true is that S3 is where
somebody goes looking, and from there SELECT BUCKET opened a combo box with
nothing in it and no sentence anywhere saying that a bucket is a keychain item.
From where the user was standing that is indistinguishable from an application
with no way to add one.

The empty state now says what a bucket is and offers a button that lands on the
keychain with the editor already open — navigating to the screen and leaving
+ BUCKET to be found among five buttons would be most of the same problem. The
phone gets the sentence and no button: its keychain screen reads and deletes and
edits nothing, so there is no editor to send anybody to, and naming the machine
that has one beats an empty control that reads as a screen still loading.

The keychain screen's layout test grew the two categories it never covered.
Tags and buckets arrived after it was written, and the header strip it measures
is one that has overflowed twice before.

**A vault can now be deleted.** DELETE /api/v1/vaults/{id}, gated on Admin —
the line the rename already drew, for a stronger version of its reason, since
this takes the vault from everybody in it at once. The row is soft-deleted and
every grant to it withdrawn in one write; VaultAccessService filters on the stamp
at both ends, so from that moment the vault is absent from every member's /me and
every call naming it answers 404. Their clients notice on the pass described
above.

The team behind it is archived when it owned nothing else, which is the mirror of
renaming it: a vault made from the vaults screen gets a team named after it that
nobody was ever shown, and leaving that behind would leave a membership list no
screen has a row for. That is a second call rather than one transaction —
archiving is TeamService's, it refuses while a team owns vaults, and it can only
tell that this one no longer does once the deletion is committed. A crash between
the two leaves an empty team: invisible, archivable afterwards, harmless, and a
better failure than a vault that could not be deleted because tidying up after it
did not work.

Two refusals worth stating. The personal vault cannot be deleted at either end:
it is created by enrollment, everything filed nowhere else lives in it, and no
call would make another. And the items are kept — ciphertext behind a vault
nothing will resolve, so deleting them buys no confidentiality while destroying
what an operator undoing a mistake would need.

The client drops the key from the keyring and the row from the cache rather than
waiting for a refresh, so the list is right immediately; the items stay, as they
stay for a vault whose grant was withdrawn, because a copy is on every other
member's machine too and removing these rows would be the client pretending to a
reach it does not have. The confirmation says that out loud before it is
answered. It is the one sentence this screen must not leave implied: deletion is
no more retroactive than revocation is. See ADR 0001.

Desktop only, deliberately. The Android vaults screen offers no rename and no
hand-over either, so adding delete alone there would be the one destructive vault
operation on a screen with no other.

Three places asserted that a vault can never be deleted — TeamService's refusal
message, the TeamNotEmpty problem code, and ADR 0009 — and each now names the
route instead.
2026-08-04 15:34:40 +02:00

787 lines
32 KiB
C#

using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using DodoSSH.Contracts;
namespace DodoSSH.Client.Api;
/// <summary>Supplies the bearer token for API calls, refreshing it when needed.</summary>
/// <remarks>
/// An abstraction because token lifetime is the auth layer's problem, not the API client's. The
/// client asks for a token per request and never caches one, so a refresh that happens mid-session is
/// invisible here rather than something every call site has to remember to handle.
/// </remarks>
public interface IAccessTokenProvider
{
/// <summary>Returns a currently-valid access token.</summary>
ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken);
}
/// <summary>
/// The account calls: who am I, and publish my first key.
/// </summary>
/// <remarks>
/// Separated for the same reason as <see cref="ISyncApi"/>. What the session layer does with these is
/// decide between enrolling and unlocking, and persist the result so the next launch needs no network;
/// testing that against a stubbed HTTP transport would prove the right bytes were sent and nothing
/// about the decision.
/// </remarks>
public interface IAccountApi
{
/// <summary>Reads the caller's profile, unlock material and reachable vaults.</summary>
Task<MeResponse> GetMeAsync(CancellationToken cancellationToken);
/// <summary>Publishes the caller's first identity key and creates their personal vault.</summary>
Task<EnrollmentResponse> EnrollAsync(EnrollmentRequest request, CancellationToken cancellationToken);
/// <summary>
/// Registers a device key against an account that is already enrolled.
/// </summary>
/// <remarks>
/// On the interface rather than only on the client, because the session layer decides <em>when</em> to
/// offer this — after an unlock, never before — and that decision is worth testing without HTTP.
/// </remarks>
Task<RegisterDeviceResponse> RegisterDeviceAsync(
RegisterDeviceRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Withdraws a device key, so that machine can no longer unlock without the passphrase.
/// </summary>
/// <returns>
/// Whether the account had that device. False means it did not, which a caller withdrawing its own
/// device should treat as having arrived rather than as a failure — another machine may have revoked it
/// first, and the goal state is the same either way.
/// </returns>
Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken);
}
/// <summary>
/// Teams, their members, and the vaults they own.
/// </summary>
/// <remarks>
/// Separated from <see cref="IVaultGrantApi"/> although the two are used together, because they are
/// different kinds of act. Everything here changes what the <em>server</em> will serve and can be
/// performed by anything holding a token. Issuing a grant needs a vault key, which only an unlocked
/// session has — so the two live behind different interfaces and are tested against different fakes.
/// </remarks>
public interface ITeamApi
{
/// <summary>Lists the teams the caller belongs to.</summary>
Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken);
/// <summary>Creates a team, with the caller as its owner.</summary>
Task<TeamSummary> CreateTeamAsync(CreateTeamRequest request, CancellationToken cancellationToken);
/// <summary>Renames a team, or changes its description.</summary>
Task<TeamSummary> UpdateTeamAsync(
Guid teamId,
UpdateTeamRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Archives a team. Refused while it still owns vaults.
/// </summary>
/// <returns>
/// Whether there was a team to archive. False means there was not, which a caller driving towards
/// "that team is gone" should treat as having arrived.
/// </returns>
Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken);
/// <summary>Hands ownership to another member, demoting the outgoing owner to admin.</summary>
Task TransferTeamOwnershipAsync(
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken);
/// <summary>Lists a team's members.</summary>
Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
CancellationToken cancellationToken);
/// <summary>Adds a member to a team.</summary>
Task<TeamMemberSummary> AddTeamMemberAsync(
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken);
/// <summary>Changes a member's role.</summary>
Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
Guid teamId,
Guid userId,
ChangeTeamMemberRoleRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Removes a member, revoking every vault key grant they hold from this team.
/// </summary>
/// <returns>
/// Whether the team had that member. False means it did not, which a caller driving towards
/// "they are not in this team" should treat as having arrived.
/// </returns>
Task<bool> RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken);
/// <summary>Lists a team's invitations, including the ones already dealt with.</summary>
Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken);
/// <summary>Invites an email address to a team.</summary>
Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Withdraws an invitation that has not been taken up.
/// </summary>
/// <returns>
/// Whether there was a live invitation to withdraw. False covers one that was never there and one
/// already claimed — a claimed invitation is a membership now, and removing a member is a different
/// operation with different consequences.
/// </returns>
Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken);
/// <summary>Creates a vault owned by a team, with the creator's key grant.</summary>
Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
CreateTeamVaultRequest request,
CancellationToken cancellationToken);
}
/// <summary>
/// The public-key directory and the log that makes it checkable.
/// </summary>
/// <remarks>
/// The two belong together and are used together: a directory answer is a claim, and the key log is
/// what turns it into something a client can verify. Splitting them would make it possible to build a
/// caller that reads one and not the other, which is precisely the mistake — see ADR 0001 — that
/// undoes end-to-end encryption entirely.
/// </remarks>
public interface IDirectoryApi
{
/// <summary>Looks a user up by exact email address. There is no search.</summary>
Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
string email,
CancellationToken cancellationToken);
/// <summary>Looks up an account the caller shares a team with.</summary>
Task<DirectoryEntry?> LookupByIdAsync(Guid userId, CancellationToken cancellationToken);
/// <summary>Reads entries after a sequence, with the log's current head.</summary>
Task<KeyLogPage> ReadKeyLogAsync(
long afterSequence,
int? limit,
CancellationToken cancellationToken);
}
/// <summary>
/// Vault key grants: who can open a vault, and the record of who let them.
/// </summary>
/// <remarks>
/// The wrapped key and the signature are produced by an unlocked session and are opaque to everything
/// between it and the recipient, this interface included.
/// </remarks>
public interface IVaultGrantApi
{
/// <summary>
/// Renames a vault.
/// </summary>
/// <remarks>
/// Here rather than on <see cref="ITeamApi"/> because the subject is a vault, and because the vaults
/// screen that calls it is about vaults — the team a vault belongs to is behind it, and renaming one
/// is not an operation on the team. The server renames that team with it where it owns nothing else.
/// </remarks>
Task<VaultSummary> RenameVaultAsync(
Guid vaultId,
UpdateVaultRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Deletes a vault and withdraws every key to it.
/// </summary>
/// <returns>Whether there was a vault to delete.</returns>
/// <remarks>
/// <para>
/// False rather than an exception for a vault that is already gone, exactly as
/// <see cref="RevokeVaultGrantAsync"/> answers about a grant: a caller driving towards "this vault is
/// no longer there" has arrived, and two admins deleting the same vault must not leave the slower one
/// looking at an error about something that happened.
/// </para>
/// <para>
/// What it does not do is reach anybody's machine. A member who synced before this holds their copy
/// afterwards — see ADR 0001 on revocation — and the interface that offers this has to say so.
/// </para>
/// </remarks>
Task<bool> DeleteVaultAsync(Guid vaultId, CancellationToken cancellationToken);
/// <summary>Lists who holds a key to this vault.</summary>
Task<VaultGrantsResponse> ListVaultGrantsAsync(Guid vaultId, CancellationToken cancellationToken);
/// <summary>Records a vault key wrapped to another member.</summary>
Task IssueVaultGrantAsync(
Guid vaultId,
IssueVaultGrantRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Advances this vault to a fresh key generation, wrapped to the caller.
/// </summary>
/// <returns>The vault at its new generation, with the caller's grants for the earlier ones.</returns>
/// <remarks>
/// The key is generated by the caller and sealed to itself; the server contributes the moment it
/// takes effect, which is the one part a client cannot decide on its own. Wrapping the new
/// generation to everybody else is a separate act, and it is the caller's — see
/// <see cref="IssueVaultGrantAsync"/>.
/// </remarks>
Task<VaultSummary> RekeyVaultAsync(
Guid vaultId,
RekeyVaultRequest request,
CancellationToken cancellationToken);
/// <summary>
/// Withdraws a member's key to this vault.
/// </summary>
/// <returns>Whether there was a live grant to withdraw.</returns>
/// <remarks>
/// Blocks future reads and nothing else. Whatever they have already pulled is on their machine;
/// the remediation for a departure is rotating the SSH credential. See ADR 0001.
/// </remarks>
Task<bool> RevokeVaultGrantAsync(
Guid vaultId,
Guid userId,
CancellationToken cancellationToken);
}
/// <summary>
/// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP.
/// </summary>
/// <remarks>
/// The sync engine's job is a conflict-resolution policy, and testing a policy against a stubbed
/// transport only proves that the right bytes were sent. Behind this interface the suite runs an
/// in-memory server that enforces the real version checks, assigns real change sequences and issues
/// real cursors — so a test can assert what happens when two clients edit one host, which is the
/// question that actually matters.
/// </remarks>
public interface ISyncApi
{
/// <summary>Reads vault changes after a cursor.</summary>
Task<SyncPullResponse> SyncPullAsync(
Guid vaultId,
SyncPullRequest request,
CancellationToken cancellationToken);
/// <summary>Applies a batch of vault changes.</summary>
Task<SyncPushResponse> SyncPushAsync(
Guid vaultId,
SyncPushRequest request,
CancellationToken cancellationToken);
}
/// <summary>
/// The typed client for one DodoSSH server.
/// </summary>
/// <remarks>
/// <para>
/// Everything goes through <c>DodoSSH.Contracts</c> and its source-generated serialiser, which is the
/// actual contract between the two sides — not the OpenAPI document. Requests are written with
/// <c>StrictRequestOptions</c> on the server and read here with <c>ResponseOptions</c>, so an older
/// client tolerates a newer server's extra fields instead of failing on them.
/// </para>
/// <para>
/// Discovery is unauthenticated by necessity: a client has to learn how to authenticate before it can.
/// Everything else carries a bearer token.
/// </para>
/// </remarks>
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
: IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi
{
private const string MetaPath = "/api/v1/meta";
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
private const string MePath = "/api/v1/me";
private const string EnrollmentPath = "/api/v1/me/enrollment";
private const string DevicesPath = "/api/v1/me/devices";
private const string DirectoryPath = "/api/v1/directory";
private const string KeyLogPath = "/api/v1/keylog";
private const string TeamsPath = "/api/v1/teams";
/// <summary>
/// Reads the server's capabilities, versions and limits.
/// </summary>
/// <remarks>
/// Unauthenticated, and the replacement for URL-based API versioning: when client and server
/// upgrade independently — normal for self-hosted software — a client has to ask what this
/// particular server supports rather than assume. See ADR 0002.
/// </remarks>
public Task<MetaResponse> GetMetaAsync(CancellationToken cancellationToken) =>
GetAnonymousAsync(MetaPath, DodoSshJsonContext.Default.MetaResponse, cancellationToken);
/// <summary>
/// Reads everything needed to begin authenticating.
/// </summary>
/// <remarks>
/// This is the onboarding story: the user types one server URL and the client discovers the OIDC
/// authority, the client id, the scopes and the relay from it.
/// </remarks>
public Task<DodoSshConfiguration> GetConfigurationAsync(CancellationToken cancellationToken) =>
GetAnonymousAsync(
ConfigurationPath,
DodoSshJsonContext.Default.DodoSshConfiguration,
cancellationToken);
/// <summary>
/// Reads the caller's profile, unlock material and reachable vaults.
/// </summary>
/// <remarks>
/// The first authenticated call a client makes, and the only one that works before enrollment. It
/// also provisions the account, so its <c>UserId</c> is available before enrolling — which matters,
/// because the secret bundle's AAD binds to that id and therefore cannot be built any earlier.
/// </remarks>
public Task<MeResponse> GetMeAsync(CancellationToken cancellationToken) =>
SendAsync(HttpMethod.Get, MePath, null, DodoSshJsonContext.Default.MeResponse, cancellationToken);
/// <summary>Publishes the caller's first identity key and creates their personal vault.</summary>
public Task<EnrollmentResponse> EnrollAsync(
EnrollmentRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
EnrollmentPath,
JsonContent.Create(request, DodoSshJsonContext.Default.EnrollmentRequest),
DodoSshJsonContext.Default.EnrollmentResponse,
cancellationToken);
/// <summary>Registers a device key against an already-enrolled account.</summary>
/// <remarks>
/// Requires an unlocked vault, because the wrap can only be produced by something holding the secret
/// bundle. That is also what proves possession to the server, which is why there is no challenge here.
/// </remarks>
public Task<RegisterDeviceResponse> RegisterDeviceAsync(
RegisterDeviceRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
DevicesPath,
JsonContent.Create(request, DodoSshJsonContext.Default.RegisterDeviceRequest),
DodoSshJsonContext.Default.RegisterDeviceResponse,
cancellationToken);
/// <inheritdoc />
public Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"{DevicesPath}/{deviceId}"),
cancellationToken);
/// <summary>Reads vault changes after a cursor.</summary>
/// <remarks>
/// A POST despite being a read: the filters live in the body, cursors are opaque, and no caching is
/// wanted.
/// </remarks>
public Task<SyncPullResponse> SyncPullAsync(
Guid vaultId,
SyncPullRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
$"/api/v1/vaults/{vaultId}/sync/pull",
JsonContent.Create(request, DodoSshJsonContext.Default.SyncPullRequest),
DodoSshJsonContext.Default.SyncPullResponse,
cancellationToken);
/// <summary>
/// Applies a batch of vault changes.
/// </summary>
/// <remarks>
/// Succeeds with per-operation status even when individual operations failed, so one stale item
/// cannot block everything else a client queued while offline. Callers must inspect
/// <c>SyncPushResult.Status</c> rather than treating a 200 as everything having applied.
/// </remarks>
public Task<SyncPushResponse> SyncPushAsync(
Guid vaultId,
SyncPushRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
$"/api/v1/vaults/{vaultId}/sync/push",
JsonContent.Create(request, DodoSshJsonContext.Default.SyncPushRequest),
DodoSshJsonContext.Default.SyncPushResponse,
cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
TeamsPath,
null,
DodoSshJsonContext.Default.IReadOnlyListTeamSummary,
cancellationToken);
/// <inheritdoc />
public Task<TeamSummary> CreateTeamAsync(
CreateTeamRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
TeamsPath,
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamRequest),
DodoSshJsonContext.Default.TeamSummary,
cancellationToken);
/// <inheritdoc />
public Task<TeamSummary> UpdateTeamAsync(
Guid teamId,
UpdateTeamRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Put,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}"),
JsonContent.Create(request, DodoSshJsonContext.Default.UpdateTeamRequest),
DodoSshJsonContext.Default.TeamSummary,
cancellationToken);
/// <inheritdoc />
public Task<bool> ArchiveTeamAsync(Guid teamId, CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}"),
cancellationToken);
/// <inheritdoc />
public Task TransferTeamOwnershipAsync(
Guid teamId,
TransferTeamOwnershipRequest request,
CancellationToken cancellationToken) =>
SendNoContentAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/owner"),
JsonContent.Create(request, DodoSshJsonContext.Default.TransferTeamOwnershipRequest),
cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
Guid teamId,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"),
null,
DodoSshJsonContext.Default.IReadOnlyListTeamMemberSummary,
cancellationToken);
/// <inheritdoc />
public Task<TeamMemberSummary> AddTeamMemberAsync(
Guid teamId,
AddTeamMemberRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"),
JsonContent.Create(request, DodoSshJsonContext.Default.AddTeamMemberRequest),
DodoSshJsonContext.Default.TeamMemberSummary,
cancellationToken);
/// <inheritdoc />
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
Guid teamId,
Guid userId,
ChangeTeamMemberRoleRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Put,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}/role"),
JsonContent.Create(request, DodoSshJsonContext.Default.ChangeTeamMemberRoleRequest),
DodoSshJsonContext.Default.TeamMemberSummary,
cancellationToken);
/// <inheritdoc />
public Task<bool> RemoveTeamMemberAsync(
Guid teamId,
Guid userId,
CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"),
cancellationToken);
/// <inheritdoc />
public Task<IReadOnlyList<TeamInvitationSummary>> ListTeamInvitationsAsync(
Guid teamId,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"),
null,
DodoSshJsonContext.Default.IReadOnlyListTeamInvitationSummary,
cancellationToken);
/// <inheritdoc />
public Task<TeamInvitationSummary> CreateTeamInvitationAsync(
Guid teamId,
CreateTeamInvitationRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations"),
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamInvitationRequest),
DodoSshJsonContext.Default.TeamInvitationSummary,
cancellationToken);
/// <inheritdoc />
public Task<bool> RevokeTeamInvitationAsync(
Guid teamId,
Guid invitationId,
CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(
CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/invitations/{invitationId}"),
cancellationToken);
/// <inheritdoc />
public Task<VaultSummary> CreateTeamVaultAsync(
Guid teamId,
CreateTeamVaultRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/vaults"),
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamVaultRequest),
DodoSshJsonContext.Default.VaultSummary,
cancellationToken);
/// <summary>
/// Looks a user up by exact email address.
/// </summary>
/// <remarks>
/// The address is escaped into the query string, which is the one place in this client where a
/// value a user typed reaches a URL. <see cref="Uri.EscapeDataString"/> rather than string
/// concatenation: an unescaped <c>&amp;</c> or <c>#</c> in an address would silently become a
/// lookup for something else.
/// </remarks>
public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
string email,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(email);
return SendAsync(
HttpMethod.Get,
$"{DirectoryPath}?email={Uri.EscapeDataString(email)}",
null,
DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry,
cancellationToken);
}
/// <inheritdoc />
public async Task<DirectoryEntry?> LookupByIdAsync(
Guid userId,
CancellationToken cancellationToken)
{
var entries = await SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"{DirectoryPath}?userId={userId}"),
null,
DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry,
cancellationToken)
.ConfigureAwait(false);
return entries.Count == 0 ? null : entries[0];
}
/// <inheritdoc />
public Task<KeyLogPage> ReadKeyLogAsync(
long afterSequence,
int? limit,
CancellationToken cancellationToken)
{
var path = limit is null
? string.Create(CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}")
: string.Create(
CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}&limit={limit}");
return SendAsync(
HttpMethod.Get, path, null, DodoSshJsonContext.Default.KeyLogPage, cancellationToken);
}
/// <inheritdoc />
public Task<VaultSummary> RenameVaultAsync(
Guid vaultId,
UpdateVaultRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Put,
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}"),
JsonContent.Create(request, DodoSshJsonContext.Default.UpdateVaultRequest),
DodoSshJsonContext.Default.VaultSummary,
cancellationToken);
/// <inheritdoc />
public Task<bool> DeleteVaultAsync(Guid vaultId, CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}"),
cancellationToken);
/// <inheritdoc />
public Task<VaultGrantsResponse> ListVaultGrantsAsync(
Guid vaultId,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Get,
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"),
null,
DodoSshJsonContext.Default.VaultGrantsResponse,
cancellationToken);
/// <inheritdoc />
public Task IssueVaultGrantAsync(
Guid vaultId,
IssueVaultGrantRequest request,
CancellationToken cancellationToken) =>
SendNoContentAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"),
JsonContent.Create(request, DodoSshJsonContext.Default.IssueVaultGrantRequest),
cancellationToken);
/// <inheritdoc />
public Task<VaultSummary> RekeyVaultAsync(
Guid vaultId,
RekeyVaultRequest request,
CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/rekey"),
JsonContent.Create(request, DodoSshJsonContext.Default.RekeyVaultRequest),
DodoSshJsonContext.Default.VaultSummary,
cancellationToken);
/// <inheritdoc />
public Task<bool> RevokeVaultGrantAsync(
Guid vaultId,
Guid userId,
CancellationToken cancellationToken) =>
DeleteAsync(
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants/{userId}"),
cancellationToken);
private async Task<T> GetAnonymousAsync<T>(
string path,
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, path);
return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false);
}
private async Task<T> SendAsync<T>(
HttpMethod method,
string path,
HttpContent? content,
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, path) { Content = content };
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return await SendCoreAsync(request, typeInfo, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Sends a request whose success carries no body.
/// </summary>
/// <remarks>
/// Its own path for the reason <see cref="DeleteAsync"/> gives, minus the 404: a grant that will
/// not be recorded, or an ownership transfer that will not happen, is a failure with a problem
/// document behind it, so there is nothing here to translate into a return value.
/// </remarks>
private async Task SendNoContentAsync(
HttpMethod method,
string path,
HttpContent? content,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, path) { Content = content };
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
throw DodoSshApiException.FromResponse(response.StatusCode, body);
}
}
/// <summary>
/// Sends a delete whose success carries no body.
/// </summary>
/// <returns>True for a 2xx, false for a 404; anything else throws.</returns>
/// <remarks>
/// Its own path rather than <see cref="SendAsync{T}"/> with some empty response type, because the two
/// disagree about what a missing body means. Everywhere else a 200 with nothing in it is a server bug
/// worth an exception; here it is the answer.
/// </remarks>
private async Task<bool> DeleteAsync(string path, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Delete, path);
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.NotFound)
{
return false;
}
if (!response.IsSuccessStatusCode)
{
var body = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
throw DodoSshApiException.FromResponse(response.StatusCode, body);
}
return true;
}
private async Task<T> SendCoreAsync<T>(
HttpRequestMessage request,
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
CancellationToken cancellationToken)
{
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content
.ReadAsStringAsync(cancellationToken)
.ConfigureAwait(false);
throw DodoSshApiException.FromResponse(response.StatusCode, body);
}
var value = await response.Content
.ReadFromJsonAsync(typeInfo, cancellationToken)
.ConfigureAwait(false);
// A 200 with a null body is a server bug, but it must not surface as a NullReferenceException
// three frames further up where the cause is invisible.
return value ?? throw new DodoSshApiException(
HttpStatusCode.OK,
null,
$"The server returned an empty body where a {typeof(T).Name} was expected.");
}
}