using DodoSSH.Api.Authorization; using DodoSSH.Api.Features.Events; using DodoSSH.Api.Setup; using DodoSSH.Contracts; using DodoSSH.Domain.Authorization; using FastEndpoints; using Microsoft.AspNetCore.Http.HttpResults; namespace DodoSSH.Api.Features.Sync; /// /// The delta read that pairs with the vault write path. /// /// /// Push is the only way vault items change; there are no per-entity POST, PUT or DELETE /// endpoints. One place therefore enforces revisions, the change log and access control, which /// halves both the endpoint count and the authorization surface. See ADR 0003. /// internal sealed class SyncPullEndpoint( ICurrentUserContext currentUser, IVaultAccessService vaultAccess, SyncService sync) : Endpoint, NotFound, ProblemHttpResult>> { /// public override void Configure() { // POST rather than GET: the filters live in the body, cursors are opaque, and no caching is // wanted. Non-mutating despite the verb. Post("/api/v1/vaults/{vaultId:guid}/sync/pull"); // Enrolled, not merely authenticated. A caller with no identity key holds no vault key // either, so it can neither produce ciphertext anyone can read nor read what is there. // Serving it would look like corruption; refusing with a code it can act on does not. Policies(Auth.EnrolledPolicy); Description(b => b .WithName("SyncPull") .WithSummary("Reads vault changes after a cursor.") .WithTags("Sync")); } /// public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( SyncPullRequest req, CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var access = await vaultAccess .ResolveAsync(user.Id, Route("vaultId"), ct) .ConfigureAwait(false); // 404 rather than 403, and identically for "absent" and "forbidden": distinguishing them is // an existence oracle for other tenants' vault ids. Decided before the cursor is looked at, // so a cursor minted for a vault the caller cannot see answers "no such vault" rather than // confirming the cursor was well-formed. if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read)) { return TypedResults.NotFound(); } try { var response = await sync.PullAsync(access.Vault!, req, ct).ConfigureAwait(false); return TypedResults.Ok(response); } catch (InvalidCursorException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.InvalidCursor, exception.Message); } } } /// /// The vault write path. /// /// /// See and ADR 0003 for why every mutation arrives here. /// internal sealed class SyncPushEndpoint( ICurrentUserContext currentUser, IVaultAccessService vaultAccess, IVaultEventPublisher events, SyncService sync) : Endpoint, NotFound, ProblemHttpResult>> { /// public override void Configure() { Post("/api/v1/vaults/{vaultId:guid}/sync/push"); Policies(Auth.EnrolledPolicy); Description(b => b .WithName("SyncPush") .WithSummary("Applies a batch of vault changes.") .WithTags("Sync")); } /// public override async Task, NotFound, ProblemHttpResult>> ExecuteAsync( SyncPushRequest req, CancellationToken ct) { var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false); var access = await vaultAccess .ResolveAsync(user.Id, Route("vaultId"), ct) .ConfigureAwait(false); if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read)) { return TypedResults.NotFound(); } // Read but not Write: the vault exists and is visible, so 403 leaks nothing here. if (!access.Permissions.HasFlag(PermissionFlags.Write)) { return Problems.Coded( StatusCodes.Status403Forbidden, ProblemCodes.Forbidden, "You do not have permission to modify this vault."); } try { // 200 even when individual operations failed. Per-operation status is in the body, so a // single stale item cannot block everything else a client queued while offline. var response = await sync.PushAsync(access.Vault!, user.Id, req, ct).ConfigureAwait(false); Announce(access.Vault!.Id, response); return TypedResults.Ok(response); } catch (PushBatchTooLargeException exception) { return Problems.Coded( StatusCodes.Status413PayloadTooLarge, ProblemCodes.PushBatchTooLarge, exception.Message); } catch (PushBatchInvalidException exception) { return Problems.Coded( StatusCodes.Status400BadRequest, ProblemCodes.PushBatchTooLarge, exception.Message); } } /// /// Tells every socket following this vault that it has moved. /// /// /// /// Here rather than inside , and that placement is the point: /// the push has committed and released the per-vault advisory lock by the time this runs. Announced /// from inside, it would name a sequence no reader could see yet and would hold the lock that /// serialises writers across a fan-out. See ADR 0003 and ADR 0012. /// /// /// The highest applied sequence, ignoring duplicates: a duplicate means an earlier push of /// that operation already landed, and it was announced then. Nothing applied means nothing to say — /// a batch of pure conflicts moved no vault, and announcing one anyway would have every client pull /// for a change that is not there. /// /// private void Announce(Guid vaultId, SyncPushResponse response) { var highest = 0L; foreach (var result in response.Results) { if (result.Status == SyncOperationStatus.Applied && result.ChangeSequence is { } sequence && sequence > highest) { highest = sequence; } } if (highest > 0) { events.VaultChanged(vaultId, highest); } } }