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);
}
}
}