diff --git a/artifacts/schema/v0.1.sql b/artifacts/schema/v0.1.sql
index fbd77a0..35e1337 100644
--- a/artifacts/schema/v0.1.sql
+++ b/artifacts/schema/v0.1.sql
@@ -14,7 +14,7 @@ START TRANSACTION;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
IF NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'dodo') THEN
CREATE SCHEMA dodo;
END IF;
@@ -23,7 +23,7 @@ END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
IF NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'dodo') THEN
CREATE SCHEMA dodo;
END IF;
@@ -32,14 +32,14 @@ END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE EXTENSION IF NOT EXISTS citext;
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE TABLE dodo.key_log (
sequence bigint GENERATED ALWAYS AS IDENTITY,
user_id uuid NOT NULL,
@@ -57,7 +57,7 @@ END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE TABLE dodo.sync_change (
sequence bigint GENERATED ALWAYS AS IDENTITY,
vault_id uuid NOT NULL,
@@ -74,7 +74,7 @@ END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE TABLE dodo.sync_operation_receipt (
operation_id uuid NOT NULL,
vault_id uuid NOT NULL,
@@ -88,7 +88,7 @@ END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE TABLE dodo.team (
id uuid NOT NULL,
name character varying(256) NOT NULL,
@@ -104,7 +104,7 @@ END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE TABLE dodo.user_account (
id uuid NOT NULL,
issuer character varying(512) NOT NULL,
@@ -124,7 +124,7 @@ END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE TABLE dodo.device (
id uuid NOT NULL,
user_id uuid NOT NULL,
@@ -142,7 +142,7 @@ END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE TABLE dodo.team_membership (
id uuid NOT NULL,
team_id uuid NOT NULL,
@@ -162,7 +162,7 @@ END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE TABLE dodo.user_key (
id uuid NOT NULL,
user_id uuid NOT NULL,
@@ -184,7 +184,7 @@ END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE TABLE dodo.vault (
id uuid NOT NULL,
name character varying(256) NOT NULL,
@@ -209,7 +209,7 @@ END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE TABLE dodo.user_key_wrap (
id uuid NOT NULL,
user_id uuid NOT NULL,
@@ -238,7 +238,7 @@ END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE TABLE dodo.host (
id uuid NOT NULL,
vault_id uuid NOT NULL,
@@ -270,7 +270,7 @@ END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE TABLE dodo.vault_key_grant (
id uuid NOT NULL,
vault_id uuid NOT NULL,
@@ -296,170 +296,170 @@ END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE INDEX ix_device_user_id ON dodo.device (user_id);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE INDEX ix_host_vault_id_change_sequence ON dodo.host (vault_id, change_sequence);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE INDEX ix_host_vault_live ON dodo.host (vault_id) WHERE deleted_at_utc IS NULL;
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE UNIQUE INDEX ix_key_log_hash ON dodo.key_log (hash);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE INDEX ix_key_log_user_id ON dodo.key_log (user_id);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE INDEX ix_sync_change_vault_id_entity_id_sequence ON dodo.sync_change (vault_id, entity_id, sequence DESC);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE INDEX ix_sync_change_vault_id_sequence ON dodo.sync_change (vault_id, sequence);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE INDEX ix_sync_operation_receipt_vault_id_created_at_utc ON dodo.sync_operation_receipt (vault_id, created_at_utc);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE UNIQUE INDEX ix_team_slug ON dodo.team (slug) WHERE deleted_at_utc IS NULL;
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE UNIQUE INDEX ix_team_membership_team_id_user_id ON dodo.team_membership (team_id, user_id) WHERE deleted_at_utc IS NULL;
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE INDEX ix_team_membership_user_id ON dodo.team_membership (user_id);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE UNIQUE INDEX ix_user_account_email ON dodo.user_account (email) WHERE email IS NOT NULL AND deleted_at_utc IS NULL;
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE UNIQUE INDEX ix_user_account_issuer_subject ON dodo.user_account (issuer, subject);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE UNIQUE INDEX ix_user_key_current ON dodo.user_key (user_id) WHERE is_current;
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE UNIQUE INDEX ix_user_key_fingerprint_sha256 ON dodo.user_key (fingerprint_sha256);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE UNIQUE INDEX ix_user_key_user_id_generation ON dodo.user_key (user_id, generation);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE INDEX ix_user_key_wrap_device_id ON dodo.user_key_wrap (device_id);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE UNIQUE INDEX ix_user_key_wrap_user_device ON dodo.user_key_wrap (user_id, device_id) WHERE device_id IS NOT NULL;
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE UNIQUE INDEX ix_user_key_wrap_user_kind ON dodo.user_key_wrap (user_id, kind) WHERE device_id IS NULL;
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE INDEX ix_vault_owner_user_id ON dodo.vault (owner_user_id);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE INDEX ix_vault_team_id ON dodo.vault (team_id);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE INDEX ix_vault_key_grant_recipient_user_id ON dodo.vault_key_grant (recipient_user_id);
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
CREATE UNIQUE INDEX ix_vault_key_grant_vault_id_key_generation_recipient_user_id ON dodo.vault_key_grant (vault_id, key_generation, recipient_user_id) WHERE revoked_at_utc IS NULL AND recipient_user_id IS NOT NULL;
END IF;
END $EF$;
DO $EF$
BEGIN
- IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728113419_InitialSchema') THEN
+ IF NOT EXISTS(SELECT 1 FROM dodo."__EFMigrationsHistory" WHERE "migration_id" = '20260728125751_InitialSchema') THEN
INSERT INTO dodo."__EFMigrationsHistory" (migration_id, product_version)
- VALUES ('20260728113419_InitialSchema', '10.0.10');
+ VALUES ('20260728125751_InitialSchema', '10.0.10');
END IF;
END $EF$;
COMMIT;
diff --git a/src/DodoSSH.Api/Authorization/CurrentUserContext.cs b/src/DodoSSH.Api/Authorization/CurrentUserContext.cs
new file mode 100644
index 0000000..34c4160
--- /dev/null
+++ b/src/DodoSSH.Api/Authorization/CurrentUserContext.cs
@@ -0,0 +1,127 @@
+using System.Security.Claims;
+using DodoSSH.Domain;
+using DodoSSH.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using Npgsql;
+
+namespace DodoSSH.Api.Authorization;
+
+/// Resolves the authenticated caller, provisioning them on first sight.
+public interface ICurrentUserContext
+{
+ ///
+ /// Returns the caller's account, creating it if this is their first authenticated request.
+ ///
+ /// The request is not authenticated.
+ Task GetOrProvisionAsync(CancellationToken cancellationToken);
+}
+
+///
+/// Request-scoped caller identity with just-in-time provisioning.
+///
+///
+/// Identity is keyed on (issuer, subject), never on email. Matching an existing account by
+/// email means anyone who can obtain a token bearing a victim's email address — from any configured
+/// provider — inherits that victim's vaults, so it is opt-in configuration and off by default.
+///
+internal sealed class CurrentUserContext(
+ IHttpContextAccessor accessor,
+ DodoDbContext database,
+ IOptions oidcOptions,
+ TimeProvider clock)
+ : ICurrentUserContext
+{
+ private UserAccount? cached;
+
+ ///
+ public async Task GetOrProvisionAsync(CancellationToken cancellationToken)
+ {
+ if (cached is not null)
+ {
+ return cached;
+ }
+
+ var principal = accessor.HttpContext?.User
+ ?? throw new InvalidOperationException("No HTTP context is available.");
+
+ if (principal.Identity?.IsAuthenticated != true)
+ {
+ throw new InvalidOperationException("The request is not authenticated.");
+ }
+
+ var issuer = RequireClaim(principal, "iss");
+ var subject = RequireClaim(principal, "sub");
+ var options = oidcOptions.Value;
+ var email = principal.FindFirstValue(options.EmailClaim);
+ var displayName = principal.FindFirstValue(options.NameClaim);
+
+ cached = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false)
+ ?? await ProvisionAsync(issuer, subject, email, displayName, cancellationToken)
+ .ConfigureAwait(false);
+
+ return cached;
+ }
+
+ private Task FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
+ database.Users.SingleOrDefaultAsync(
+ u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null,
+ cancellationToken);
+
+ private async Task ProvisionAsync(
+ string issuer,
+ string subject,
+ string? email,
+ string? displayName,
+ CancellationToken cancellationToken)
+ {
+ var now = clock.GetUtcNow();
+
+ var user = new UserAccount
+ {
+ Id = Guid.CreateVersion7(),
+ Issuer = issuer,
+ Subject = subject,
+ Email = email,
+ DisplayName = displayName,
+ Status = UserStatus.Active,
+ CreatedAtUtc = now,
+ UpdatedAtUtc = now,
+ LastSeenAtUtc = now,
+ };
+
+ database.Users.Add(user);
+
+ try
+ {
+ await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+ return user;
+ }
+ catch (DbUpdateException exception)
+ when (string.Equals(
+ (exception.InnerException as PostgresException)?.SqlState,
+ PostgresErrorCodes.UniqueViolation,
+ StringComparison.Ordinal))
+ {
+ // Two concurrent first requests from the same new user. The unique index on
+ // (issuer, subject) is what makes this safe: one insert wins and the other reads it
+ // back, rather than both proceeding with a duplicate account.
+ database.Entry(user).State = EntityState.Detached;
+
+ var winner = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false);
+ if (winner is not null)
+ {
+ return winner;
+ }
+
+ // The unique violation came from something other than the race we expected — for
+ // instance the partial unique index on email. Do not swallow it.
+ throw;
+ }
+ }
+
+ private static string RequireClaim(ClaimsPrincipal principal, string claimType) =>
+ principal.FindFirstValue(claimType)
+ ?? throw new InvalidOperationException(
+ $"The access token is missing the required '{claimType}' claim.");
+}
diff --git a/src/DodoSSH.Api/Authorization/VaultAccessService.cs b/src/DodoSSH.Api/Authorization/VaultAccessService.cs
new file mode 100644
index 0000000..9893161
--- /dev/null
+++ b/src/DodoSSH.Api/Authorization/VaultAccessService.cs
@@ -0,0 +1,82 @@
+using DodoSSH.Domain;
+using DodoSSH.Domain.Authorization;
+using DodoSSH.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+
+namespace DodoSSH.Api.Authorization;
+
+/// The result of an access check.
+/// The vault, when access was granted.
+/// The caller's effective permissions.
+public readonly record struct VaultAccess(Vault? Vault, PermissionFlags Permissions)
+{
+ /// Whether the caller may act on the vault at all.
+ public bool Granted => Vault is not null;
+
+ /// Denied access.
+ public static VaultAccess Denied => new(null, PermissionFlags.None);
+}
+
+/// Resolves what the caller may do with a vault.
+public interface IVaultAccessService
+{
+ ///
+ /// Resolves the caller's effective permissions on a vault.
+ ///
+ ///
+ /// Returns both when the vault does not exist and when the
+ /// caller cannot see it. Deliberately indistinguishable: a distinct "exists but forbidden"
+ /// answer is an existence oracle that lets a caller enumerate other tenants' vault ids.
+ ///
+ Task ResolveAsync(Guid userId, Guid vaultId, CancellationToken cancellationToken);
+}
+
+///
+/// Vault access resolution.
+///
+///
+///
+/// M1 supports personal vaults only, so the rule is ownership. Team vaults, the
+/// v_user_vault_permission view and per-resource ACLs arrive in M3 — this is the one place
+/// that changes, which is why every caller goes through it rather than comparing owner ids inline.
+///
+///
+/// A team vault is explicitly denied for now rather than falling through to a permissive default.
+/// Failing closed on an unimplemented path is the only safe direction.
+///
+///
+internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessService
+{
+ /// Everything the owner of a personal vault may do with it.
+ private const PermissionFlags OwnerPermissions =
+ PermissionFlags.Read
+ | PermissionFlags.Write
+ | PermissionFlags.Connect
+ | PermissionFlags.Share
+ | PermissionFlags.Admin;
+
+ ///
+ public async Task ResolveAsync(
+ Guid userId,
+ Guid vaultId,
+ CancellationToken cancellationToken)
+ {
+ var vault = await database.Vaults
+ .SingleOrDefaultAsync(v => v.Id == vaultId && v.DeletedAtUtc == null, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (vault is null)
+ {
+ return VaultAccess.Denied;
+ }
+
+ if (vault.OwnerKind == VaultOwnerKind.Personal && vault.OwnerUserId == userId)
+ {
+ return new VaultAccess(vault, OwnerPermissions);
+ }
+
+ // Team vaults are not readable until M3 wires up membership and grants. Denying is the
+ // correct behaviour in the meantime.
+ return VaultAccess.Denied;
+ }
+}
diff --git a/src/DodoSSH.Api/Features/Sync/CursorKeyProvider.cs b/src/DodoSSH.Api/Features/Sync/CursorKeyProvider.cs
new file mode 100644
index 0000000..21846c0
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Sync/CursorKeyProvider.cs
@@ -0,0 +1,85 @@
+using System.Security.Cryptography;
+using DodoSSH.Api.Setup;
+using DodoSSH.Domain.Sync;
+using Microsoft.Extensions.Options;
+
+namespace DodoSSH.Api.Features.Sync;
+
+/// Supplies the key that tags sync cursors.
+public interface ICursorKeyProvider
+{
+ /// The signing key.
+ ReadOnlySpan Key { get; }
+}
+
+///
+/// Resolves the cursor signing key from configuration, generating an ephemeral one if unset.
+///
+///
+///
+/// Losing this key is harmless: it only invalidates in-flight cursors, and a client that gets a
+/// rejected cursor resyncs from the beginning. That is why an unconfigured deployment gets a random
+/// per-process key rather than a startup failure — it works, and the only cost is that cursors do
+/// not survive a restart or span multiple nodes.
+///
+///
+/// Multi-node deployments must configure it explicitly, or a cursor issued by one node will be
+/// rejected by another and clients will resync constantly.
+///
+///
+internal sealed class CursorKeyProvider : ICursorKeyProvider
+{
+ private readonly byte[] key;
+
+ public CursorKeyProvider(IOptions options, ILogger logger)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentNullException.ThrowIfNull(logger);
+
+ var configured = options.Value.CursorSigningKey;
+
+ if (string.IsNullOrWhiteSpace(configured))
+ {
+ key = RandomNumberGenerator.GetBytes(SyncCursor.MinimumKeyLength);
+ CursorKeyLog.EphemeralKeyGenerated(logger);
+ return;
+ }
+
+ if (!TryDecodeKey(configured, out key))
+ {
+ throw new InvalidOperationException(
+ "Sync:CursorSigningKey must be base64 and decode to at least "
+ + $"{SyncCursor.MinimumKeyLength} bytes.");
+ }
+ }
+
+ ///
+ public ReadOnlySpan Key => key;
+
+ private static bool TryDecodeKey(string configured, out byte[] key)
+ {
+ key = [];
+
+ Span buffer = new byte[configured.Length];
+ if (!Convert.TryFromBase64String(configured, buffer, out var written)
+ || written < SyncCursor.MinimumKeyLength)
+ {
+ return false;
+ }
+
+ key = buffer[..written].ToArray();
+ return true;
+ }
+}
+
+internal static partial class CursorKeyLog
+{
+ [LoggerMessage(
+ EventId = 2010,
+ Level = LogLevel.Warning,
+ Message = "Sync:CursorSigningKey is not configured, so an ephemeral per-process key was "
+ + "generated. Cursors will not survive a restart and will be rejected across nodes, "
+ + "causing clients to resync from the beginning. Configure it for any multi-node or "
+ + "production deployment.")]
+ internal static partial void EphemeralKeyGenerated(ILogger logger);
+}
diff --git a/src/DodoSSH.Api/Features/Sync/SyncEndpoints.cs b/src/DodoSSH.Api/Features/Sync/SyncEndpoints.cs
new file mode 100644
index 0000000..878bfc7
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Sync/SyncEndpoints.cs
@@ -0,0 +1,124 @@
+using DodoSSH.Api.Authorization;
+using DodoSSH.Api.Setup;
+using DodoSSH.Contracts;
+using DodoSSH.Domain.Authorization;
+using Microsoft.AspNetCore.Http.HttpResults;
+
+namespace DodoSSH.Api.Features.Sync;
+
+///
+/// The vault write path, and the delta read that pairs with it.
+///
+///
+/// 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 static class SyncEndpoints
+{
+ internal static IEndpointRouteBuilder MapSyncEndpoints(this IEndpointRouteBuilder app)
+ {
+ var group = app.MapGroup("/api/v1/vaults/{vaultId:guid}/sync")
+ .RequireAuthorization(Auth.AuthenticatedPolicy)
+ .WithTags("Sync");
+
+ // POST rather than GET: the filters live in the body, cursors are opaque, and no caching is
+ // wanted. Non-mutating despite the verb.
+ group.MapPost("/pull", PullAsync)
+ .WithName("SyncPull")
+ .WithSummary("Reads vault changes after a cursor.");
+
+ group.MapPost("/push", PushAsync)
+ .WithName("SyncPush")
+ .WithSummary("Applies a batch of vault changes.");
+
+ return app;
+ }
+
+ private static async Task, NotFound, ProblemHttpResult>> PullAsync(
+ Guid vaultId,
+ SyncPullRequest request,
+ ICurrentUserContext currentUser,
+ IVaultAccessService vaultAccess,
+ SyncService sync,
+ CancellationToken cancellationToken)
+ {
+ var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
+ var access = await vaultAccess.ResolveAsync(user.Id, vaultId, cancellationToken)
+ .ConfigureAwait(false);
+
+ // 404 rather than 403, and identically for "absent" and "forbidden": distinguishing them is
+ // an existence oracle for other tenants' vault ids.
+ if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
+ {
+ return TypedResults.NotFound();
+ }
+
+ try
+ {
+ var response = await sync.PullAsync(access.Vault!, request, cancellationToken)
+ .ConfigureAwait(false);
+
+ return TypedResults.Ok(response);
+ }
+ catch (InvalidCursorException exception)
+ {
+ return Problem(StatusCodes.Status400BadRequest, ProblemCodes.InvalidCursor, exception.Message);
+ }
+ }
+
+ private static async Task, NotFound, ProblemHttpResult>> PushAsync(
+ Guid vaultId,
+ SyncPushRequest request,
+ ICurrentUserContext currentUser,
+ IVaultAccessService vaultAccess,
+ SyncService sync,
+ CancellationToken cancellationToken)
+ {
+ var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
+ var access = await vaultAccess.ResolveAsync(user.Id, vaultId, cancellationToken)
+ .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 Problem(
+ 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, request, cancellationToken)
+ .ConfigureAwait(false);
+
+ return TypedResults.Ok(response);
+ }
+ catch (PushBatchTooLargeException exception)
+ {
+ return Problem(
+ StatusCodes.Status413PayloadTooLarge,
+ ProblemCodes.PushBatchTooLarge,
+ exception.Message);
+ }
+ catch (PushBatchInvalidException exception)
+ {
+ return Problem(StatusCodes.Status400BadRequest, ProblemCodes.PushBatchTooLarge, exception.Message);
+ }
+ }
+
+ private static ProblemHttpResult Problem(int statusCode, string code, string detail) =>
+ TypedResults.Problem(
+ detail: detail,
+ statusCode: statusCode,
+ type: ProblemCodes.TypeBaseUri + code,
+ extensions: new Dictionary(StringComparer.Ordinal) { ["code"] = code });
+}
diff --git a/src/DodoSSH.Api/Features/Sync/SyncExceptions.cs b/src/DodoSSH.Api/Features/Sync/SyncExceptions.cs
new file mode 100644
index 0000000..7e7654c
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Sync/SyncExceptions.cs
@@ -0,0 +1,11 @@
+namespace DodoSSH.Api.Features.Sync;
+
+/// A cursor was malformed, mis-tagged, or issued for another vault.
+public sealed class InvalidCursorException() : Exception(
+ "The sync cursor is not valid for this vault. Resync from the beginning.");
+
+/// A push batch exceeded a configured cap.
+public sealed class PushBatchTooLargeException(string message) : Exception(message);
+
+/// A push batch was structurally unusable as a whole.
+public sealed class PushBatchInvalidException(string message) : Exception(message);
diff --git a/src/DodoSSH.Api/Features/Sync/SyncLog.cs b/src/DodoSSH.Api/Features/Sync/SyncLog.cs
new file mode 100644
index 0000000..cdb3856
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Sync/SyncLog.cs
@@ -0,0 +1,30 @@
+namespace DodoSSH.Api.Features.Sync;
+
+/// Source-generated log messages for sync.
+///
+/// Deliberately records ids, versions and counts only. Payloads are ciphertext, but their sizes and
+/// access patterns still leak, and there is no diagnostic value in them.
+///
+internal static partial class SyncLog
+{
+ [LoggerMessage(
+ EventId = 2001,
+ Level = LogLevel.Information,
+ Message = "Push conflict on item {EntityId}: client expected version {ExpectedVersion}, "
+ + "server holds {ServerVersion}.")]
+ internal static partial void PushConflict(
+ ILogger logger,
+ Guid entityId,
+ int? expectedVersion,
+ int? serverVersion);
+
+ [LoggerMessage(
+ EventId = 2002,
+ Level = LogLevel.Information,
+ Message = "Applied {AppliedCount} of {OperationCount} operation(s) to vault {VaultId}.")]
+ internal static partial void PushApplied(
+ ILogger logger,
+ int appliedCount,
+ int operationCount,
+ Guid vaultId);
+}
diff --git a/src/DodoSSH.Api/Features/Sync/SyncService.cs b/src/DodoSSH.Api/Features/Sync/SyncService.cs
new file mode 100644
index 0000000..8f33a92
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Sync/SyncService.cs
@@ -0,0 +1,602 @@
+using DodoSSH.Api.Setup;
+using DodoSSH.Contracts;
+using DodoSSH.Domain;
+using DodoSSH.Domain.Sync;
+using DodoSSH.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Options;
+using Npgsql;
+
+namespace DodoSSH.Api.Features.Sync;
+
+/// Applies and reads a vault's change log.
+internal sealed class SyncService(
+ DodoDbContext database,
+ IOptions syncOptions,
+ ICursorKeyProvider cursorKeys,
+ TimeProvider clock,
+ ILogger logger)
+{
+ private readonly SyncOptions options = syncOptions.Value;
+
+ /// Reads changes after a cursor.
+ internal async Task PullAsync(
+ Vault vault,
+ SyncPullRequest request,
+ CancellationToken cancellationToken)
+ {
+ var afterSequence = 0L;
+ if (!string.IsNullOrEmpty(request.Cursor)
+ && !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence))
+ {
+ throw new InvalidCursorException();
+ }
+
+ var limit = Math.Clamp(
+ request.Limit ?? options.DefaultPullLimit,
+ 1,
+ options.MaxPullLimit);
+
+ var types = request.EntityTypes is { Count: > 0 }
+ ? request.EntityTypes.Select(ToDomain).ToArray()
+ : null;
+
+ // One extra row, so "is there more" needs no second query.
+ var query = database.VaultChanges
+ .Where(c => c.VaultId == vault.Id && c.Sequence > afterSequence);
+
+ if (types is not null)
+ {
+ query = query.Where(c => types.Contains(c.EntityType));
+ }
+
+ var changes = await query
+ .OrderBy(c => c.Sequence)
+ .Take(limit + 1)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ var hasMore = changes.Count > limit;
+ if (hasMore)
+ {
+ changes.RemoveAt(changes.Count - 1);
+ }
+
+ var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
+
+ // When nothing came back the cursor must not move, or a concurrent write landing between
+ // this read and the next would be skipped forever.
+ var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
+
+ return new SyncPullResponse(
+ Changes: hydrated,
+ NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence),
+ HasMore: hasMore,
+ ServerTime: clock.GetUtcNow(),
+ CurrentKeyGeneration: (uint)vault.KeyGeneration);
+ }
+
+ ///
+ /// Applies a batch of operations.
+ ///
+ ///
+ ///
+ /// The whole batch runs in one transaction whose first statement takes a per-vault
+ /// advisory lock. That is load-bearing: bigserial hands out sequence values before
+ /// commit, so without serialising writers per vault, transaction A can take sequence 5 while B
+ /// takes 6 and commits first. A reader advancing its cursor to 6 then misses 5 permanently.
+ /// See ADR 0003.
+ ///
+ ///
+ /// Individual operations that conflict are skipped rather than aborting the batch, so one stale
+ /// item cannot block everything else a client queued while offline.
+ ///
+ ///
+ internal async Task PushAsync(
+ Vault vault,
+ Guid actorUserId,
+ SyncPushRequest request,
+ CancellationToken cancellationToken)
+ {
+ ValidateBatchLimits(request);
+
+ var strategy = database.Database.CreateExecutionStrategy();
+
+ return await strategy.ExecuteAsync(async () =>
+ {
+ var transaction = await database.Database
+ .BeginTransactionAsync(cancellationToken)
+ .ConfigureAwait(false);
+ await using var _ = transaction.ConfigureAwait(false);
+
+ await AcquireVaultLockAsync(vault.Id, cancellationToken).ConfigureAwait(false);
+
+ var results = new List(request.Operations.Count);
+ var highestSequence = 0L;
+
+ foreach (var operation in request.Operations)
+ {
+ var result = await ApplyAsync(vault, actorUserId, operation, cancellationToken)
+ .ConfigureAwait(false);
+
+ results.Add(result);
+
+ if (result.ChangeSequence is { } sequence && sequence > highestSequence)
+ {
+ highestSequence = sequence;
+ }
+ }
+
+ await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
+
+ // If nothing was applied, report the log head so the client does not rewind.
+ if (highestSequence == 0)
+ {
+ highestSequence = await CurrentHeadAsync(vault.Id, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ return new SyncPushResponse(
+ Results: results,
+ Cursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, highestSequence));
+ }).ConfigureAwait(false);
+ }
+
+ private void ValidateBatchLimits(SyncPushRequest request)
+ {
+ if (request.Operations.Count == 0)
+ {
+ throw new PushBatchInvalidException("A push must contain at least one operation.");
+ }
+
+ if (request.Operations.Count > options.MaxOperationsPerPush)
+ {
+ throw new PushBatchTooLargeException(
+ $"A push may contain at most {options.MaxOperationsPerPush} operations; "
+ + $"{request.Operations.Count} were supplied.");
+ }
+
+ long total = 0;
+ foreach (var operation in request.Operations)
+ {
+ var length = operation.Payload?.Envelope.Length ?? 0;
+
+ if (length > options.MaxItemPayloadBytes)
+ {
+ throw new PushBatchTooLargeException(
+ $"Item {operation.EntityId} payload is {length} bytes; the limit is "
+ + $"{options.MaxItemPayloadBytes}.");
+ }
+
+ total += length;
+ }
+
+ if (total > options.MaxPayloadBytes)
+ {
+ throw new PushBatchTooLargeException(
+ $"Total push payload is {total} bytes; the limit is {options.MaxPayloadBytes}.");
+ }
+ }
+
+ ///
+ /// Serialises writers to one vault for the life of the transaction.
+ ///
+ ///
+ /// hashtextextended over the vault id gives a stable 64-bit lock key. Contention is
+ /// per-vault, and a push is a single transaction anyway, so the cost is negligible next to the
+ /// silent sync corruption it prevents. Requires Multiplexing=false on the connection,
+ /// which is the Npgsql default — enabling it would break the lock's session affinity.
+ ///
+ private Task AcquireVaultLockAsync(Guid vaultId, CancellationToken cancellationToken) =>
+ database.Database.ExecuteSqlAsync(
+ $"SELECT pg_advisory_xact_lock(hashtextextended({vaultId.ToString()}, 0))",
+ cancellationToken);
+
+ private async Task ApplyAsync(
+ Vault vault,
+ Guid actorUserId,
+ SyncPushOperation operation,
+ CancellationToken cancellationToken)
+ {
+ if (operation.EntityType != SyncEntityType.Host)
+ {
+ // M1 syncs hosts only. Other types are reserved in the contract so a newer client
+ // gets a precise per-operation answer rather than a whole-batch failure.
+ return Invalid(operation, $"Entity type {operation.EntityType} is not yet supported.");
+ }
+
+ if (operation.Operation is not (SyncOperation.Upsert or SyncOperation.Delete))
+ {
+ return Invalid(operation, "Operation must be Upsert or Delete.");
+ }
+
+ // Exactly-once at operation granularity. A batch-level idempotency key alone would
+ // double-apply the operations that did land when a client retries a partially-overlapping
+ // batch after a timeout.
+ var receipt = await database.SyncOperationReceipts
+ .SingleOrDefaultAsync(r => r.OperationId == operation.OperationId, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (receipt is not null)
+ {
+ return new SyncPushResult(
+ operation.OperationId,
+ SyncOperationStatus.Duplicate,
+ receipt.ResultVersion,
+ receipt.AppliedChangeSequence,
+ ServerEntity: null,
+ Detail: null);
+ }
+
+ var host = await database.Hosts
+ .SingleOrDefaultAsync(h => h.Id == operation.EntityId, cancellationToken)
+ .ConfigureAwait(false);
+
+ // An id that exists in another vault must not be addressable from this one, and must not
+ // reveal that it exists elsewhere.
+ if (host is not null && host.VaultId != vault.Id)
+ {
+ return new SyncPushResult(
+ operation.OperationId,
+ SyncOperationStatus.Forbidden,
+ Version: null,
+ ChangeSequence: null,
+ ServerEntity: null,
+ Detail: null);
+ }
+
+ return operation.Operation == SyncOperation.Delete
+ ? await ApplyDeleteAsync(vault, actorUserId, operation, host, cancellationToken)
+ .ConfigureAwait(false)
+ : await ApplyUpsertAsync(vault, actorUserId, operation, host, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ private async Task ApplyUpsertAsync(
+ Vault vault,
+ Guid actorUserId,
+ SyncPushOperation operation,
+ SshHost? existing,
+ CancellationToken cancellationToken)
+ {
+ if (operation.Payload is null)
+ {
+ return Invalid(operation, "An upsert requires a payload.");
+ }
+
+ var fields = operation.PlaintextFields ?? new SyncPlaintextFields();
+
+ if (!ValidateRelayFields(fields, out var relayError))
+ {
+ return Invalid(operation, relayError);
+ }
+
+ var now = clock.GetUtcNow();
+
+ if (existing is null || existing.DeletedAtUtc is not null)
+ {
+ return await CreateAsync(
+ vault, actorUserId, operation, existing, fields, now, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ if (operation.ExpectedVersion != existing.Version)
+ {
+ return await ConflictAsync(vault, operation, existing, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ existing.Version++;
+ ApplyFields(existing, operation.Payload, fields, actorUserId, now);
+
+ return await RecordAsync(
+ vault, actorUserId, operation, existing, ChangeOperation.Upsert, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ /// Creates a new item, or rejects an upsert that cannot become one.
+ private async Task CreateAsync(
+ Vault vault,
+ Guid actorUserId,
+ SyncPushOperation operation,
+ SshHost? existing,
+ SyncPlaintextFields fields,
+ DateTimeOffset now,
+ CancellationToken cancellationToken)
+ {
+ // A tombstone wins over a late upsert. The client is told, so it can resurrect the item
+ // deliberately under a new id rather than silently undoing someone else's delete.
+ if (existing?.DeletedAtUtc is not null)
+ {
+ return await ConflictAsync(vault, operation, existing, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ if (operation.ExpectedVersion is not null)
+ {
+ // The client believes it is updating something that does not exist here.
+ return await ConflictAsync(vault, operation, existing: null, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ var created = new SshHost
+ {
+ Id = operation.EntityId,
+ VaultId = vault.Id,
+ Version = 1,
+ CreatedAtUtc = now,
+ CreatedByUserId = actorUserId,
+ };
+
+ ApplyFields(created, operation.Payload!, fields, actorUserId, now);
+ database.Hosts.Add(created);
+
+ return await RecordAsync(
+ vault, actorUserId, operation, created, ChangeOperation.Upsert, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ private async Task ApplyDeleteAsync(
+ Vault vault,
+ Guid actorUserId,
+ SyncPushOperation operation,
+ SshHost? existing,
+ CancellationToken cancellationToken)
+ {
+ if (existing is null)
+ {
+ return Invalid(operation, "Cannot delete an item that does not exist.");
+ }
+
+ if (existing.DeletedAtUtc is not null)
+ {
+ // Already a tombstone. Idempotent rather than an error: a client retrying a delete it
+ // is unsure about should not have to distinguish these.
+ return new SyncPushResult(
+ operation.OperationId,
+ SyncOperationStatus.Applied,
+ existing.Version,
+ existing.ChangeSequence,
+ ServerEntity: null,
+ Detail: null);
+ }
+
+ if (operation.ExpectedVersion is not null && operation.ExpectedVersion != existing.Version)
+ {
+ return await ConflictAsync(vault, operation, existing, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ var now = clock.GetUtcNow();
+ existing.Version++;
+ existing.DeletedAtUtc = now;
+ existing.UpdatedAtUtc = now;
+ existing.UpdatedByUserId = actorUserId;
+
+ // The address must go with the item. Leaving it would keep the server able to resolve a
+ // host the user believes they deleted.
+ existing.RelayEnabled = false;
+ existing.Hostname = null;
+ existing.Port = null;
+
+ return await RecordAsync(
+ vault, actorUserId, operation, existing, ChangeOperation.Delete, cancellationToken)
+ .ConfigureAwait(false);
+ }
+
+ private static void ApplyFields(
+ SshHost host,
+ EncryptedPayload payload,
+ SyncPlaintextFields fields,
+ Guid actorUserId,
+ DateTimeOffset now)
+ {
+ host.Payload = payload.Envelope;
+ host.KeyGeneration = (int)payload.KeyGeneration;
+ host.PayloadAadVersion = payload.AadVersion;
+ host.RelayEnabled = fields.RelayEnabled;
+ host.Hostname = fields.RelayEnabled ? fields.Hostname : null;
+ host.Port = fields.RelayEnabled ? fields.Port : null;
+ host.GroupId = fields.GroupId;
+ host.DeletedAtUtc = null;
+ host.UpdatedAtUtc = now;
+ host.UpdatedByUserId = actorUserId;
+ }
+
+ ///
+ /// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a
+ /// constraint violation surfacing as a 500.
+ ///
+ private static bool ValidateRelayFields(SyncPlaintextFields fields, out string error)
+ {
+ error = string.Empty;
+
+ if (fields.RelayEnabled)
+ {
+ if (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null)
+ {
+ error = "Relay-enabled hosts require both a hostname and a port.";
+ return false;
+ }
+
+ if (fields.Port is < 1 or > 65535)
+ {
+ error = "Port must be between 1 and 65535.";
+ return false;
+ }
+
+ return true;
+ }
+
+ if (fields.Hostname is not null || fields.Port is not null)
+ {
+ error = "A hostname or port may only be supplied when relay is enabled for the host.";
+ return false;
+ }
+
+ return true;
+ }
+
+ private async Task RecordAsync(
+ Vault vault,
+ Guid actorUserId,
+ SyncPushOperation operation,
+ SshHost host,
+ ChangeOperation changeOperation,
+ CancellationToken cancellationToken)
+ {
+ var change = new VaultChange
+ {
+ VaultId = vault.Id,
+ EntityType = ChangeEntityType.SshHost,
+ EntityId = host.Id,
+ Operation = changeOperation,
+ Revision = host.Version,
+ ActorUserId = actorUserId,
+ OccurredAtUtc = clock.GetUtcNow(),
+ };
+
+ database.VaultChanges.Add(change);
+
+ // Saved before the denormalised pointer is set, because the sequence is assigned by the
+ // database on insert.
+ await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ host.ChangeSequence = change.Sequence;
+
+ database.SyncOperationReceipts.Add(new SyncOperationReceipt
+ {
+ OperationId = operation.OperationId,
+ VaultId = vault.Id,
+ AppliedChangeSequence = change.Sequence,
+ ResultVersion = host.Version,
+ CreatedAtUtc = clock.GetUtcNow(),
+ });
+
+ await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ return new SyncPushResult(
+ operation.OperationId,
+ SyncOperationStatus.Applied,
+ host.Version,
+ change.Sequence,
+ ServerEntity: null,
+ Detail: null);
+ }
+
+ private async Task ConflictAsync(
+ Vault vault,
+ SyncPushOperation operation,
+ SshHost? existing,
+ CancellationToken cancellationToken)
+ {
+ // The server cannot merge ciphertext, so it hands back its current state and the client
+ // performs a three-way merge against its retained common ancestor. Never last-writer-wins.
+ Contracts.SyncChange? serverEntity = null;
+
+ if (existing is not null)
+ {
+ var hydrated = await HydrateAsync(
+ vault,
+ [new VaultChange
+ {
+ Sequence = existing.ChangeSequence,
+ VaultId = existing.VaultId,
+ EntityType = ChangeEntityType.SshHost,
+ EntityId = existing.Id,
+ Operation = existing.DeletedAtUtc is null
+ ? ChangeOperation.Upsert
+ : ChangeOperation.Delete,
+ Revision = existing.Version,
+ OccurredAtUtc = existing.UpdatedAtUtc,
+ }],
+ cancellationToken).ConfigureAwait(false);
+
+ serverEntity = hydrated.Count > 0 ? hydrated[0] : null;
+ }
+
+ SyncLog.PushConflict(logger, operation.EntityId, operation.ExpectedVersion, existing?.Version);
+
+ return new SyncPushResult(
+ operation.OperationId,
+ SyncOperationStatus.Conflict,
+ existing?.Version,
+ existing?.ChangeSequence,
+ serverEntity,
+ Detail: null);
+ }
+
+ /// Attaches current row state to change-log entries.
+ private async Task> HydrateAsync(
+ Vault vault,
+ List changes,
+ CancellationToken cancellationToken)
+ {
+ var hostIds = changes
+ .Where(c => c.EntityType == ChangeEntityType.SshHost)
+ .Select(c => c.EntityId)
+ .Distinct()
+ .ToArray();
+
+ var hosts = hostIds.Length == 0
+ ? []
+ : await database.Hosts
+ .Where(h => h.VaultId == vault.Id && hostIds.Contains(h.Id))
+ .ToDictionaryAsync(h => h.Id, cancellationToken)
+ .ConfigureAwait(false);
+
+ var result = new List(changes.Count);
+
+ foreach (var change in changes)
+ {
+ hosts.TryGetValue(change.EntityId, out var host);
+
+ // A delete carries no payload: there is nothing left to decrypt, and shipping the
+ // pre-delete ciphertext would undermine the point of the tombstone.
+ var isDelete = change.Operation == ChangeOperation.Delete
+ || host?.DeletedAtUtc is not null;
+
+ result.Add(new Contracts.SyncChange(
+ EntityType: SyncEntityType.Host,
+ EntityId: change.EntityId,
+ Operation: isDelete ? SyncOperation.Delete : SyncOperation.Upsert,
+ Version: change.Revision,
+ ChangeSequence: change.Sequence,
+ Payload: isDelete || host is null
+ ? null
+ : new EncryptedPayload(
+ host.Payload,
+ (uint)host.KeyGeneration,
+ (byte)host.PayloadAadVersion),
+ PlaintextFields: isDelete || host is null
+ ? null
+ : new SyncPlaintextFields(
+ RelayEnabled: host.RelayEnabled,
+ Hostname: host.Hostname,
+ Port: host.Port,
+ GroupId: host.GroupId),
+ UpdatedAt: change.OccurredAtUtc));
+ }
+
+ return result;
+ }
+
+ private async Task CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) =>
+ await database.VaultChanges
+ .Where(c => c.VaultId == vaultId)
+ .OrderByDescending(c => c.Sequence)
+ .Select(c => c.Sequence)
+ .FirstOrDefaultAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
+ new(
+ operation.OperationId,
+ SyncOperationStatus.Invalid,
+ Version: null,
+ ChangeSequence: null,
+ ServerEntity: null,
+ Detail: detail);
+
+ private static ChangeEntityType ToDomain(SyncEntityType type) => (ChangeEntityType)(int)type;
+}
diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs
index 8c627c8..f877ce9 100644
--- a/src/DodoSSH.Api/Program.cs
+++ b/src/DodoSSH.Api/Program.cs
@@ -1,3 +1,5 @@
+using DodoSSH.Api.Authorization;
+using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Setup;
var builder = WebApplication.CreateBuilder(args);
@@ -16,6 +18,13 @@ builder.Services.AddDodoHealthChecks();
// TimeProvider so time can be faked in tests.
builder.Services.AddSingleton(TimeProvider.System);
+builder.Services.AddHttpContextAccessor();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddSingleton();
+builder.Services.AddProblemDetails();
+
var app = builder.Build();
// Deliberately no UseHttpsRedirection: the API is always fronted by a reverse proxy
diff --git a/src/DodoSSH.Api/Setup/Auth.cs b/src/DodoSSH.Api/Setup/Auth.cs
index 93ef70c..1c63bb5 100644
--- a/src/DodoSSH.Api/Setup/Auth.cs
+++ b/src/DodoSSH.Api/Setup/Auth.cs
@@ -47,6 +47,11 @@ internal static class Auth
// Tokens are the one thing that must never reach a log or a trace.
options.IncludeErrorDetails = false;
+
+ // Keep claim names as the provider issued them. The default mapping rewrites
+ // "sub" to a long WS-Federation URI, which makes provider-agnostic claim
+ // configuration confusing and silently breaks when a provider is swapped.
+ options.MapInboundClaims = false;
});
services.AddAuthorization(options =>
diff --git a/src/DodoSSH.Api/Setup/EndpointRegistration.cs b/src/DodoSSH.Api/Setup/EndpointRegistration.cs
index 0b1bd84..3d2fa4e 100644
--- a/src/DodoSSH.Api/Setup/EndpointRegistration.cs
+++ b/src/DodoSSH.Api/Setup/EndpointRegistration.cs
@@ -1,4 +1,5 @@
using DodoSSH.Api.Features.Meta;
+using DodoSSH.Api.Features.Sync;
namespace DodoSSH.Api.Setup;
@@ -16,12 +17,12 @@ internal static class EndpointRegistration
internal static WebApplication MapDodoEndpoints(this WebApplication app)
{
app.MapMetaEndpoints();
+ app.MapSyncEndpoints();
// Registered as each feature lands:
// Identity — /me, enrollment, key rotation, devices
// Directory — public-key lookup
// Vaults — grants, rekey, ACL
- // Sync — pull and push
// Relay — tickets and the WebSocket
// Teams, Audit, Admin
return app;
diff --git a/src/DodoSSH.Domain/Hosts.cs b/src/DodoSSH.Domain/Hosts.cs
index 4512ad1..4e7a444 100644
--- a/src/DodoSSH.Domain/Hosts.cs
+++ b/src/DodoSSH.Domain/Hosts.cs
@@ -17,7 +17,7 @@ namespace DodoSSH.Domain;
/// constraint enforces the pairing so it cannot drift. See ADR 0004.
///
///
-public sealed class Host
+public sealed class SshHost
{
/// Primary key. UUIDv7, generated by the client so items can be created offline.
public Guid Id { get; set; }
diff --git a/src/DodoSSH.Domain/Sync/SyncCursor.cs b/src/DodoSSH.Domain/Sync/SyncCursor.cs
new file mode 100644
index 0000000..154e92a
--- /dev/null
+++ b/src/DodoSSH.Domain/Sync/SyncCursor.cs
@@ -0,0 +1,193 @@
+using System.Buffers.Binary;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace DodoSSH.Domain.Sync;
+
+///
+/// An opaque, integrity-tagged position in a vault's change log.
+///
+///
+///
+/// Clients must never construct or modify one. The tag exists so a tampered cursor is
+/// rejected rather than silently mis-serving: an untagged cursor would let a caller
+/// rewind to sequence 0 and re-read everything, or skip forward and permanently miss changes
+/// while believing it was up to date. Silent data loss is far worse than an error.
+///
+///
+/// The cursor also carries its vault id, so a legitimately-issued cursor for one vault cannot be
+/// replayed against another. See ADR 0003.
+///
+///
+public static class SyncCursor
+{
+ private const string Version = "v1";
+ private const char Separator = '|';
+
+ /// Length of the truncated HMAC tag. 128 bits is ample for a non-secret position.
+ private const int TagLength = 16;
+
+ /// Minimum signing key length.
+ public const int MinimumKeyLength = 32;
+
+ ///
+ /// Encodes a position.
+ ///
+ /// Deployment cursor signing key, at least 32 bytes.
+ /// Vault the cursor belongs to.
+ /// Last change sequence the client has consumed.
+ public static string Encode(ReadOnlySpan signingKey, Guid vaultId, long sequence)
+ {
+ RequireKey(signingKey);
+
+ if (sequence < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must not be negative.");
+ }
+
+ var payload = Encoding.UTF8.GetBytes(FormatPayload(vaultId, sequence));
+
+ var buffer = new byte[payload.Length + TagLength];
+ payload.CopyTo(buffer, 0);
+ ComputeTag(signingKey, payload, buffer.AsSpan(payload.Length, TagLength));
+
+ return Base64Url.Encode(buffer);
+ }
+
+ ///
+ /// Decodes and verifies a cursor.
+ ///
+ ///
+ /// Returns false for anything malformed, mis-tagged, or issued for a different vault. It never
+ /// throws on bad input: cursors arrive from clients, so rejection is an expected outcome.
+ ///
+ /// Deployment cursor signing key.
+ /// The cursor to verify.
+ /// Vault the request is scoped to.
+ /// The decoded sequence, when verification succeeds.
+ public static bool TryDecode(
+ ReadOnlySpan signingKey,
+ string? cursor,
+ Guid expectedVaultId,
+ out long sequence)
+ {
+ RequireKey(signingKey);
+ sequence = 0;
+
+ if (string.IsNullOrEmpty(cursor))
+ {
+ return false;
+ }
+
+ if (!Base64Url.TryDecode(cursor, out var buffer) || buffer.Length <= TagLength)
+ {
+ return false;
+ }
+
+ var payload = buffer.AsSpan(0, buffer.Length - TagLength);
+ var providedTag = buffer.AsSpan(buffer.Length - TagLength, TagLength);
+
+ Span expectedTag = stackalloc byte[TagLength];
+ ComputeTag(signingKey, payload, expectedTag);
+
+ // Constant-time: a timing oracle here would let an attacker forge a tag byte by byte.
+ if (!CryptographicOperations.FixedTimeEquals(providedTag, expectedTag))
+ {
+ return false;
+ }
+
+ return TryParsePayload(payload, expectedVaultId, out sequence);
+ }
+
+ private static string FormatPayload(Guid vaultId, long sequence) =>
+ string.Create(
+ CultureInfo.InvariantCulture,
+ $"{Version}{Separator}{vaultId:D}{Separator}{sequence}");
+
+ private static bool TryParsePayload(ReadOnlySpan payload, Guid expectedVaultId, out long sequence)
+ {
+ sequence = 0;
+
+ string text;
+ try
+ {
+ text = Encoding.UTF8.GetString(payload);
+ }
+ catch (DecoderFallbackException)
+ {
+ return false;
+ }
+
+ // Tagged, so the shape is ours — but parse defensively anyway, because a key rotation
+ // could make an old format verify against a new expectation.
+ var parts = text.Split(Separator);
+ if (parts.Length != 3)
+ {
+ return false;
+ }
+
+ if (!string.Equals(parts[0], Version, StringComparison.Ordinal))
+ {
+ return false;
+ }
+
+ if (!Guid.TryParseExact(parts[1], "D", out var vaultId) || vaultId != expectedVaultId)
+ {
+ return false;
+ }
+
+ return long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out sequence)
+ && sequence >= 0;
+ }
+
+ private static void ComputeTag(
+ ReadOnlySpan signingKey,
+ ReadOnlySpan payload,
+ Span destination)
+ {
+ Span full = stackalloc byte[HMACSHA256.HashSizeInBytes];
+ HMACSHA256.HashData(signingKey, payload, full);
+ full[..TagLength].CopyTo(destination);
+ }
+
+ private static void RequireKey(ReadOnlySpan signingKey)
+ {
+ if (signingKey.Length < MinimumKeyLength)
+ {
+ throw new ArgumentException(
+ $"Cursor signing key must be at least {MinimumKeyLength} bytes.",
+ nameof(signingKey));
+ }
+ }
+}
+
+/// Base64url without padding, as used in URLs and headers.
+internal static class Base64Url
+{
+ internal static string Encode(ReadOnlySpan value) =>
+ Convert.ToBase64String(value).TrimEnd('=').Replace('+', '-').Replace('/', '_');
+
+ internal static bool TryDecode(string value, out byte[] result)
+ {
+ result = [];
+
+ var normalised = value.Replace('-', '+').Replace('_', '/');
+ var padding = (4 - (normalised.Length % 4)) % 4;
+ if (padding == 3)
+ {
+ // A length of 4n+1 cannot be valid base64.
+ return false;
+ }
+
+ try
+ {
+ result = Convert.FromBase64String(normalised + new string('=', padding));
+ return true;
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ }
+}
diff --git a/src/DodoSSH.Domain/SyncLog.cs b/src/DodoSSH.Domain/SyncLog.cs
index eabe5f9..10c08a7 100644
--- a/src/DodoSSH.Domain/SyncLog.cs
+++ b/src/DodoSSH.Domain/SyncLog.cs
@@ -20,7 +20,7 @@ public enum ChangeEntityType
Unspecified = 0,
/// An SSH host.
- Host = 1,
+ SshHost = 1,
/// A credential. M2.
Credential = 2,
@@ -65,7 +65,7 @@ public enum ChangeEntityType
/// statement, so sequence order equals commit order. See ADR 0003.
///
///
-public sealed class SyncChange
+public sealed class VaultChange
{
/// Monotonic sequence. Database-generated.
public long Sequence { get; set; }
diff --git a/src/DodoSSH.Domain/Vaults.cs b/src/DodoSSH.Domain/Vaults.cs
index 2765ac8..fe670b2 100644
--- a/src/DodoSSH.Domain/Vaults.cs
+++ b/src/DodoSSH.Domain/Vaults.cs
@@ -56,7 +56,7 @@ public sealed class Vault
public ICollection KeyGrants { get; } = [];
/// Hosts in this vault.
- public ICollection Hosts { get; } = [];
+ public ICollection Hosts { get; } = [];
}
///
diff --git a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs
index 09fa072..cc7c282 100644
--- a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs
+++ b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs
@@ -4,11 +4,11 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace DodoSSH.Infrastructure.Configurations;
-/// Maps .
-public sealed class HostConfiguration : IEntityTypeConfiguration
+/// Maps .
+public sealed class HostConfiguration : IEntityTypeConfiguration
{
///
- public void Configure(EntityTypeBuilder builder)
+ public void Configure(EntityTypeBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
@@ -49,11 +49,11 @@ public sealed class HostConfiguration : IEntityTypeConfiguration
}
}
-/// Maps .
-public sealed class SyncChangeConfiguration : IEntityTypeConfiguration
+/// Maps .
+public sealed class SyncChangeConfiguration : IEntityTypeConfiguration
{
///
- public void Configure(EntityTypeBuilder builder)
+ public void Configure(EntityTypeBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);
diff --git a/src/DodoSSH.Infrastructure/DodoDbContext.cs b/src/DodoSSH.Infrastructure/DodoDbContext.cs
index 41ed19e..19ffa21 100644
--- a/src/DodoSSH.Infrastructure/DodoDbContext.cs
+++ b/src/DodoSSH.Infrastructure/DodoDbContext.cs
@@ -52,10 +52,10 @@ public class DodoDbContext(DbContextOptions options) : DbContext(
public DbSet VaultKeyGrants => Set();
/// SSH hosts.
- public DbSet Hosts => Set();
+ public DbSet Hosts => Set();
/// The per-vault change log that delta sync reads.
- public DbSet SyncChanges => Set();
+ public DbSet VaultChanges => Set();
/// Applied-operation receipts, for exactly-once retries.
public DbSet SyncOperationReceipts => Set();
diff --git a/src/DodoSSH.Infrastructure/Migrations/20260728113419_InitialSchema.Designer.cs b/src/DodoSSH.Infrastructure/Migrations/20260728125751_InitialSchema.Designer.cs
similarity index 99%
rename from src/DodoSSH.Infrastructure/Migrations/20260728113419_InitialSchema.Designer.cs
rename to src/DodoSSH.Infrastructure/Migrations/20260728125751_InitialSchema.Designer.cs
index 1065711..393fdcb 100644
--- a/src/DodoSSH.Infrastructure/Migrations/20260728113419_InitialSchema.Designer.cs
+++ b/src/DodoSSH.Infrastructure/Migrations/20260728125751_InitialSchema.Designer.cs
@@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace DodoSSH.Infrastructure.Migrations
{
[DbContext(typeof(DodoDbContext))]
- [Migration("20260728113419_InitialSchema")]
+ [Migration("20260728125751_InitialSchema")]
partial class InitialSchema
{
///
@@ -74,7 +74,71 @@ namespace DodoSSH.Infrastructure.Migrations
b.ToTable("device", "dodo");
});
- modelBuilder.Entity("DodoSSH.Domain.Host", b =>
+ modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b =>
+ {
+ b.Property("Sequence")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("sequence");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence"));
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("EncryptionPublicKey")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("encryption_public_key");
+
+ b.Property("Generation")
+ .HasColumnType("integer")
+ .HasColumnName("generation");
+
+ b.Property("Hash")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("hash");
+
+ b.Property("PreviousHash")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("previous_hash");
+
+ b.Property("SigningPublicKey")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("signing_public_key");
+
+ b.Property("StatementSignature")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("bytea")
+ .HasColumnName("statement_signature");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Sequence")
+ .HasName("pk_key_log");
+
+ b.HasIndex("Hash")
+ .IsUnique()
+ .HasDatabaseName("ix_key_log_hash");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_key_log_user_id");
+
+ b.ToTable("key_log", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
{
b.Property("Id")
.HasColumnType("uuid")
@@ -176,120 +240,6 @@ namespace DodoSSH.Infrastructure.Migrations
});
});
- modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b =>
- {
- b.Property("Sequence")
- .ValueGeneratedOnAdd()
- .HasColumnType("bigint")
- .HasColumnName("sequence");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence"));
-
- b.Property("CreatedAtUtc")
- .HasColumnType("timestamp with time zone")
- .HasColumnName("created_at_utc");
-
- b.Property("EncryptionPublicKey")
- .IsRequired()
- .HasMaxLength(32)
- .HasColumnType("bytea")
- .HasColumnName("encryption_public_key");
-
- b.Property("Generation")
- .HasColumnType("integer")
- .HasColumnName("generation");
-
- b.Property("Hash")
- .IsRequired()
- .HasMaxLength(32)
- .HasColumnType("bytea")
- .HasColumnName("hash");
-
- b.Property("PreviousHash")
- .IsRequired()
- .HasMaxLength(32)
- .HasColumnType("bytea")
- .HasColumnName("previous_hash");
-
- b.Property("SigningPublicKey")
- .IsRequired()
- .HasMaxLength(32)
- .HasColumnType("bytea")
- .HasColumnName("signing_public_key");
-
- b.Property("StatementSignature")
- .IsRequired()
- .HasMaxLength(64)
- .HasColumnType("bytea")
- .HasColumnName("statement_signature");
-
- b.Property("UserId")
- .HasColumnType("uuid")
- .HasColumnName("user_id");
-
- b.HasKey("Sequence")
- .HasName("pk_key_log");
-
- b.HasIndex("Hash")
- .IsUnique()
- .HasDatabaseName("ix_key_log_hash");
-
- b.HasIndex("UserId")
- .HasDatabaseName("ix_key_log_user_id");
-
- b.ToTable("key_log", "dodo");
- });
-
- modelBuilder.Entity("DodoSSH.Domain.SyncChange", b =>
- {
- b.Property("Sequence")
- .ValueGeneratedOnAdd()
- .HasColumnType("bigint")
- .HasColumnName("sequence");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence"));
-
- b.Property("ActorUserId")
- .HasColumnType("uuid")
- .HasColumnName("actor_user_id");
-
- b.Property("EntityId")
- .HasColumnType("uuid")
- .HasColumnName("entity_id");
-
- b.Property("EntityType")
- .HasColumnType("integer")
- .HasColumnName("entity_type");
-
- b.Property("OccurredAtUtc")
- .HasColumnType("timestamp with time zone")
- .HasColumnName("occurred_at_utc");
-
- b.Property("Operation")
- .HasColumnType("integer")
- .HasColumnName("operation");
-
- b.Property("Revision")
- .HasColumnType("integer")
- .HasColumnName("revision");
-
- b.Property("VaultId")
- .HasColumnType("uuid")
- .HasColumnName("vault_id");
-
- b.HasKey("Sequence")
- .HasName("pk_sync_change");
-
- b.HasIndex("VaultId", "Sequence")
- .HasDatabaseName("ix_sync_change_vault_id_sequence");
-
- b.HasIndex("VaultId", "EntityId", "Sequence")
- .IsDescending(false, false, true)
- .HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence");
-
- b.ToTable("sync_change", "dodo");
- });
-
modelBuilder.Entity("DodoSSH.Domain.SyncOperationReceipt", b =>
{
b.Property("OperationId")
@@ -740,6 +690,56 @@ namespace DodoSSH.Infrastructure.Migrations
});
});
+ modelBuilder.Entity("DodoSSH.Domain.VaultChange", b =>
+ {
+ b.Property("Sequence")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("sequence");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence"));
+
+ b.Property("ActorUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("actor_user_id");
+
+ b.Property("EntityId")
+ .HasColumnType("uuid")
+ .HasColumnName("entity_id");
+
+ b.Property("EntityType")
+ .HasColumnType("integer")
+ .HasColumnName("entity_type");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurred_at_utc");
+
+ b.Property("Operation")
+ .HasColumnType("integer")
+ .HasColumnName("operation");
+
+ b.Property("Revision")
+ .HasColumnType("integer")
+ .HasColumnName("revision");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.HasKey("Sequence")
+ .HasName("pk_sync_change");
+
+ b.HasIndex("VaultId", "Sequence")
+ .HasDatabaseName("ix_sync_change_vault_id_sequence");
+
+ b.HasIndex("VaultId", "EntityId", "Sequence")
+ .IsDescending(false, false, true)
+ .HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence");
+
+ b.ToTable("sync_change", "dodo");
+ });
+
modelBuilder.Entity("DodoSSH.Domain.VaultKeyGrant", b =>
{
b.Property("Id")
@@ -841,7 +841,7 @@ namespace DodoSSH.Infrastructure.Migrations
b.Navigation("User");
});
- modelBuilder.Entity("DodoSSH.Domain.Host", b =>
+ modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
{
b.HasOne("DodoSSH.Domain.Vault", "Vault")
.WithMany("Hosts")
diff --git a/src/DodoSSH.Infrastructure/Migrations/20260728113419_InitialSchema.cs b/src/DodoSSH.Infrastructure/Migrations/20260728125751_InitialSchema.cs
similarity index 100%
rename from src/DodoSSH.Infrastructure/Migrations/20260728113419_InitialSchema.cs
rename to src/DodoSSH.Infrastructure/Migrations/20260728125751_InitialSchema.cs
diff --git a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs
index 9b97254..b57000d 100644
--- a/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs
+++ b/src/DodoSSH.Infrastructure/Migrations/DodoDbContextModelSnapshot.cs
@@ -71,7 +71,71 @@ namespace DodoSSH.Infrastructure.Migrations
b.ToTable("device", "dodo");
});
- modelBuilder.Entity("DodoSSH.Domain.Host", b =>
+ modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b =>
+ {
+ b.Property("Sequence")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("sequence");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence"));
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("EncryptionPublicKey")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("encryption_public_key");
+
+ b.Property("Generation")
+ .HasColumnType("integer")
+ .HasColumnName("generation");
+
+ b.Property("Hash")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("hash");
+
+ b.Property("PreviousHash")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("previous_hash");
+
+ b.Property("SigningPublicKey")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("signing_public_key");
+
+ b.Property("StatementSignature")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("bytea")
+ .HasColumnName("statement_signature");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Sequence")
+ .HasName("pk_key_log");
+
+ b.HasIndex("Hash")
+ .IsUnique()
+ .HasDatabaseName("ix_key_log_hash");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_key_log_user_id");
+
+ b.ToTable("key_log", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
{
b.Property("Id")
.HasColumnType("uuid")
@@ -173,120 +237,6 @@ namespace DodoSSH.Infrastructure.Migrations
});
});
- modelBuilder.Entity("DodoSSH.Domain.KeyLogEntry", b =>
- {
- b.Property("Sequence")
- .ValueGeneratedOnAdd()
- .HasColumnType("bigint")
- .HasColumnName("sequence");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence"));
-
- b.Property("CreatedAtUtc")
- .HasColumnType("timestamp with time zone")
- .HasColumnName("created_at_utc");
-
- b.Property("EncryptionPublicKey")
- .IsRequired()
- .HasMaxLength(32)
- .HasColumnType("bytea")
- .HasColumnName("encryption_public_key");
-
- b.Property("Generation")
- .HasColumnType("integer")
- .HasColumnName("generation");
-
- b.Property("Hash")
- .IsRequired()
- .HasMaxLength(32)
- .HasColumnType("bytea")
- .HasColumnName("hash");
-
- b.Property("PreviousHash")
- .IsRequired()
- .HasMaxLength(32)
- .HasColumnType("bytea")
- .HasColumnName("previous_hash");
-
- b.Property("SigningPublicKey")
- .IsRequired()
- .HasMaxLength(32)
- .HasColumnType("bytea")
- .HasColumnName("signing_public_key");
-
- b.Property("StatementSignature")
- .IsRequired()
- .HasMaxLength(64)
- .HasColumnType("bytea")
- .HasColumnName("statement_signature");
-
- b.Property("UserId")
- .HasColumnType("uuid")
- .HasColumnName("user_id");
-
- b.HasKey("Sequence")
- .HasName("pk_key_log");
-
- b.HasIndex("Hash")
- .IsUnique()
- .HasDatabaseName("ix_key_log_hash");
-
- b.HasIndex("UserId")
- .HasDatabaseName("ix_key_log_user_id");
-
- b.ToTable("key_log", "dodo");
- });
-
- modelBuilder.Entity("DodoSSH.Domain.SyncChange", b =>
- {
- b.Property("Sequence")
- .ValueGeneratedOnAdd()
- .HasColumnType("bigint")
- .HasColumnName("sequence");
-
- NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence"));
-
- b.Property("ActorUserId")
- .HasColumnType("uuid")
- .HasColumnName("actor_user_id");
-
- b.Property("EntityId")
- .HasColumnType("uuid")
- .HasColumnName("entity_id");
-
- b.Property("EntityType")
- .HasColumnType("integer")
- .HasColumnName("entity_type");
-
- b.Property("OccurredAtUtc")
- .HasColumnType("timestamp with time zone")
- .HasColumnName("occurred_at_utc");
-
- b.Property("Operation")
- .HasColumnType("integer")
- .HasColumnName("operation");
-
- b.Property("Revision")
- .HasColumnType("integer")
- .HasColumnName("revision");
-
- b.Property("VaultId")
- .HasColumnType("uuid")
- .HasColumnName("vault_id");
-
- b.HasKey("Sequence")
- .HasName("pk_sync_change");
-
- b.HasIndex("VaultId", "Sequence")
- .HasDatabaseName("ix_sync_change_vault_id_sequence");
-
- b.HasIndex("VaultId", "EntityId", "Sequence")
- .IsDescending(false, false, true)
- .HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence");
-
- b.ToTable("sync_change", "dodo");
- });
-
modelBuilder.Entity("DodoSSH.Domain.SyncOperationReceipt", b =>
{
b.Property("OperationId")
@@ -737,6 +687,56 @@ namespace DodoSSH.Infrastructure.Migrations
});
});
+ modelBuilder.Entity("DodoSSH.Domain.VaultChange", b =>
+ {
+ b.Property("Sequence")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint")
+ .HasColumnName("sequence");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Sequence"));
+
+ b.Property("ActorUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("actor_user_id");
+
+ b.Property("EntityId")
+ .HasColumnType("uuid")
+ .HasColumnName("entity_id");
+
+ b.Property("EntityType")
+ .HasColumnType("integer")
+ .HasColumnName("entity_type");
+
+ b.Property("OccurredAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("occurred_at_utc");
+
+ b.Property("Operation")
+ .HasColumnType("integer")
+ .HasColumnName("operation");
+
+ b.Property("Revision")
+ .HasColumnType("integer")
+ .HasColumnName("revision");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.HasKey("Sequence")
+ .HasName("pk_sync_change");
+
+ b.HasIndex("VaultId", "Sequence")
+ .HasDatabaseName("ix_sync_change_vault_id_sequence");
+
+ b.HasIndex("VaultId", "EntityId", "Sequence")
+ .IsDescending(false, false, true)
+ .HasDatabaseName("ix_sync_change_vault_id_entity_id_sequence");
+
+ b.ToTable("sync_change", "dodo");
+ });
+
modelBuilder.Entity("DodoSSH.Domain.VaultKeyGrant", b =>
{
b.Property("Id")
@@ -838,7 +838,7 @@ namespace DodoSSH.Infrastructure.Migrations
b.Navigation("User");
});
- modelBuilder.Entity("DodoSSH.Domain.Host", b =>
+ modelBuilder.Entity("DodoSSH.Domain.SshHost", b =>
{
b.HasOne("DodoSSH.Domain.Vault", "Vault")
.WithMany("Hosts")
diff --git a/tests/DodoSSH.Domain.Tests/Sync/SyncCursorTests.cs b/tests/DodoSSH.Domain.Tests/Sync/SyncCursorTests.cs
new file mode 100644
index 0000000..3231e4c
--- /dev/null
+++ b/tests/DodoSSH.Domain.Tests/Sync/SyncCursorTests.cs
@@ -0,0 +1,155 @@
+using System.Security.Cryptography;
+using DodoSSH.Domain.Sync;
+
+namespace DodoSSH.Domain.Tests.Sync;
+
+///
+/// Cursor encoding and, more importantly, every way a bad cursor must be rejected.
+///
+///
+/// The negative cases are the point. An accepted-but-wrong cursor causes silent data loss — the
+/// client believes it is up to date while having skipped changes — which is strictly worse than an
+/// error the client can retry from scratch.
+///
+public sealed class SyncCursorTests
+{
+ private static readonly byte[] Key = RandomNumberGenerator.GetBytes(32);
+ private static readonly Guid VaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e0f");
+ private static readonly Guid OtherVaultId = Guid.Parse("0192f0c8-1a2b-7c3d-8e4f-5a6b7c8d9e10");
+
+ [Theory]
+ [InlineData(0L)]
+ [InlineData(1L)]
+ [InlineData(42L)]
+ [InlineData(long.MaxValue)]
+ public void RoundTrips(long sequence)
+ {
+ var cursor = SyncCursor.Encode(Key, VaultId, sequence);
+
+ SyncCursor.TryDecode(Key, cursor, VaultId, out var decoded).ShouldBeTrue();
+ decoded.ShouldBe(sequence);
+ }
+
+ [Fact]
+ public void IsDeterministic()
+ {
+ SyncCursor.Encode(Key, VaultId, 7).ShouldBe(SyncCursor.Encode(Key, VaultId, 7));
+ }
+
+ [Fact]
+ public void IsUrlSafeAndUnpadded()
+ {
+ var cursor = SyncCursor.Encode(Key, VaultId, 12345);
+
+ cursor.ShouldNotContain("+");
+ cursor.ShouldNotContain("/");
+ cursor.ShouldNotContain("=");
+ }
+
+ [Fact]
+ public void DoesNotRevealTheSequenceInPlainSight()
+ {
+ // Not a security property — the position is not secret — but it discourages clients from
+ // parsing or synthesising cursors, which is what the opacity is actually for.
+ SyncCursor.Encode(Key, VaultId, 987654).ShouldNotContain("987654");
+ }
+
+ [Fact]
+ public void RejectsATamperedTag()
+ {
+ var cursor = SyncCursor.Encode(Key, VaultId, 100);
+ var tampered = cursor[..^1] + (cursor[^1] == 'A' ? 'B' : 'A');
+
+ SyncCursor.TryDecode(Key, tampered, VaultId, out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void RejectsATamperedPayload()
+ {
+ // The attack this prevents: rewriting the sequence to skip ahead, so the client never
+ // learns about the changes in between.
+ var cursor = SyncCursor.Encode(Key, VaultId, 100);
+ var mutated = cursor.ToCharArray();
+ mutated[0] = mutated[0] == 'x' ? 'y' : 'x';
+
+ SyncCursor.TryDecode(Key, new string(mutated), VaultId, out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void RejectsACursorSignedWithAnotherKey()
+ {
+ var foreign = SyncCursor.Encode(RandomNumberGenerator.GetBytes(32), VaultId, 100);
+
+ SyncCursor.TryDecode(Key, foreign, VaultId, out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void RejectsACursorIssuedForAnotherVault()
+ {
+ // Legitimately issued and correctly tagged, but for a different vault. Without the vault
+ // id inside the payload this would decode to a sequence from an unrelated log and serve
+ // the wrong slice of history.
+ var cursor = SyncCursor.Encode(Key, OtherVaultId, 100);
+
+ SyncCursor.TryDecode(Key, cursor, VaultId, out _).ShouldBeFalse();
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData("not-base64!!")]
+ [InlineData("AAAA")]
+ [InlineData("A")]
+ public void RejectsMalformedInput(string? cursor)
+ {
+ SyncCursor.TryDecode(Key, cursor, VaultId, out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void RejectsATruncatedCursor()
+ {
+ var cursor = SyncCursor.Encode(Key, VaultId, 100);
+
+ SyncCursor.TryDecode(Key, cursor[..(cursor.Length / 2)], VaultId, out _).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void NeverThrowsOnClientSuppliedInput()
+ {
+ // Cursors come from clients, so rejection must be a return value rather than an exception
+ // that becomes a 500.
+ string[] hostile =
+ [
+ "\0", "…", new string('A', 10_000), "____", "----", "v1|x|y", "%%%",
+ ];
+
+ foreach (var value in hostile)
+ {
+ Should.NotThrow(() => SyncCursor.TryDecode(Key, value, VaultId, out _));
+ }
+ }
+
+ [Fact]
+ public void RejectsAnUndersizedSigningKey()
+ {
+ // Misconfiguration must fail loudly at the call site rather than producing weak tags.
+ Should.Throw(() => SyncCursor.Encode(new byte[16], VaultId, 1));
+ Should.Throw(() =>
+ SyncCursor.TryDecode(new byte[31], "whatever", VaultId, out _));
+ }
+
+ [Fact]
+ public void RejectsANegativeSequence()
+ {
+ Should.Throw(() => SyncCursor.Encode(Key, VaultId, -1));
+ }
+
+ [Fact]
+ public void DifferentSequencesProduceDifferentCursors()
+ {
+ var first = SyncCursor.Encode(Key, VaultId, 1);
+ var second = SyncCursor.Encode(Key, VaultId, 2);
+
+ string.Equals(first, second, StringComparison.Ordinal).ShouldBeFalse();
+ }
+}
diff --git a/tests/DodoSSH.Infrastructure.Tests/AdvisoryLockOrderingTests.cs b/tests/DodoSSH.Infrastructure.Tests/AdvisoryLockOrderingTests.cs
new file mode 100644
index 0000000..9db5005
--- /dev/null
+++ b/tests/DodoSSH.Infrastructure.Tests/AdvisoryLockOrderingTests.cs
@@ -0,0 +1,217 @@
+using DodoSSH.Domain;
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+
+namespace DodoSSH.Infrastructure.Tests;
+
+///
+/// Proves the per-vault advisory lock makes change-log sequence order equal commit order.
+///
+///
+///
+/// This is the executable form of ADR 0003's central hazard. bigserial assigns sequence
+/// values when the INSERT runs, not when the transaction commits. So without serialising
+/// writers per vault, transaction A can take sequence 5 while B takes 6, and B can commit first.
+/// A reader that polls in between sees only 6, advances its cursor past 5, and never learns about
+/// it — silent, permanent data loss for that item.
+///
+///
+/// The first test demonstrates the hazard is real by reproducing it without the lock. The
+/// second shows the lock removes it. Without the first, the second proves nothing: it would pass
+/// just as happily if the interleaving never occurred.
+///
+///
+[Collection(PostgresCollection.Name)]
+public sealed class AdvisoryLockOrderingTests(PostgresFixture fixture)
+{
+ private static readonly DateTimeOffset Now = new(2026, 7, 28, 12, 0, 0, TimeSpan.Zero);
+
+ [Fact]
+ public async Task WithoutTheLock_ACommittedGapIsObservable()
+ {
+ // Deliberately reproduces the bug. If this ever stops failing to produce a gap, either
+ // PostgreSQL's sequence behaviour changed or the test stopped interleaving — and the
+ // guarantee the next test claims would no longer be meaningful.
+ var vaultId = await SeedVaultAsync();
+
+ await using var first = new NpgsqlConnection(fixture.ConnectionString);
+ await using var second = new NpgsqlConnection(fixture.ConnectionString);
+ await first.OpenAsync();
+ await second.OpenAsync();
+
+ await using var firstTransaction = await first.BeginTransactionAsync();
+ await using var secondTransaction = await second.BeginTransactionAsync();
+
+ // A takes the lower sequence...
+ var lowSequence = await InsertChangeAsync(first, firstTransaction, vaultId);
+
+ // ...B takes the higher one, and commits first.
+ var highSequence = await InsertChangeAsync(second, secondTransaction, vaultId);
+ await secondTransaction.CommitAsync();
+
+ highSequence.ShouldBeGreaterThan(lowSequence);
+
+ // A reader now sees the high sequence but not the low one. Advancing a cursor to
+ // highSequence would skip lowSequence forever once A commits.
+ var visible = await VisibleSequencesAsync(vaultId);
+ visible.ShouldContain(highSequence);
+ visible.ShouldNotContain(lowSequence);
+
+ await firstTransaction.CommitAsync();
+
+ // And here is the damage: the skipped row is now visible, but behind the cursor.
+ var afterBothCommitted = await VisibleSequencesAsync(vaultId);
+ afterBothCommitted.ShouldContain(lowSequence);
+ afterBothCommitted.ShouldContain(highSequence);
+ }
+
+ [Fact]
+ public async Task WithTheLock_SequenceOrderMatchesCommitOrder()
+ {
+ var vaultId = await SeedVaultAsync();
+
+ await using var first = new NpgsqlConnection(fixture.ConnectionString);
+ await using var second = new NpgsqlConnection(fixture.ConnectionString);
+ await first.OpenAsync();
+ await second.OpenAsync();
+
+ await using var firstTransaction = await first.BeginTransactionAsync();
+ await AcquireVaultLockAsync(first, firstTransaction, vaultId);
+ var lowSequence = await InsertChangeAsync(first, firstTransaction, vaultId);
+
+ // The second writer blocks on the lock rather than racing ahead to a higher sequence.
+ await using var secondTransaction = await second.BeginTransactionAsync();
+ var blocked = Task.Run(async () =>
+ {
+ await AcquireVaultLockAsync(second, secondTransaction, vaultId);
+ var sequence = await InsertChangeAsync(second, secondTransaction, vaultId)
+ ;
+ await secondTransaction.CommitAsync();
+ return sequence;
+ });
+
+ // Give it a moment to prove it really is waiting, not merely slow.
+ var completedEarly = await Task.WhenAny(blocked, Task.Delay(TimeSpan.FromMilliseconds(750)))
+ ;
+ completedEarly.ShouldNotBe(blocked, "the second writer should be blocked on the advisory lock");
+
+ await firstTransaction.CommitAsync();
+
+ var highSequence = await blocked;
+
+ // The writer that committed first holds the lower sequence, so a reader advancing its
+ // cursor monotonically can never skip a committed row.
+ highSequence.ShouldBeGreaterThan(lowSequence);
+
+ var visible = await VisibleSequencesAsync(vaultId);
+ visible.ShouldContain(lowSequence);
+ visible.ShouldContain(highSequence);
+ }
+
+ [Fact]
+ public async Task WithTheLock_ConcurrentWritersProduceNoGaps()
+ {
+ // The property that actually matters, under real contention: reading the log in sequence
+ // order after every writer has committed must yield a contiguous, complete set.
+ var vaultId = await SeedVaultAsync();
+ const int Writers = 12;
+
+ var tasks = Enumerable.Range(0, Writers).Select(async _ =>
+ {
+ await using var connection = new NpgsqlConnection(fixture.ConnectionString);
+ await connection.OpenAsync();
+ await using var transaction = await connection.BeginTransactionAsync();
+
+ await AcquireVaultLockAsync(connection, transaction, vaultId);
+ var sequence = await InsertChangeAsync(connection, transaction, vaultId);
+
+ await transaction.CommitAsync();
+ return sequence;
+ });
+
+ var sequences = await Task.WhenAll(tasks);
+
+ sequences.Distinct().Count().ShouldBe(Writers);
+
+ var visible = await VisibleSequencesAsync(vaultId);
+ visible.Count.ShouldBe(Writers);
+ visible.OrderBy(s => s).ShouldBe(sequences.OrderBy(s => s));
+ }
+
+ private static Task AcquireVaultLockAsync(
+ NpgsqlConnection connection,
+ NpgsqlTransaction transaction,
+ Guid vaultId)
+ {
+ var command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = "SELECT pg_advisory_xact_lock(hashtextextended(@vault::text, 0))";
+ command.Parameters.AddWithValue("vault", vaultId);
+ return command.ExecuteNonQueryAsync();
+ }
+
+ private static async Task InsertChangeAsync(
+ NpgsqlConnection connection,
+ NpgsqlTransaction transaction,
+ Guid vaultId)
+ {
+ var command = connection.CreateCommand();
+ command.Transaction = transaction;
+ command.CommandText = """
+ INSERT INTO dodo.sync_change
+ (vault_id, entity_type, entity_id, operation, revision, actor_user_id, occurred_at_utc)
+ VALUES (@vault, 1, @entity, 1, 1, @actor, now())
+ RETURNING sequence
+ """;
+ command.Parameters.AddWithValue("vault", vaultId);
+ command.Parameters.AddWithValue("entity", Guid.CreateVersion7());
+ command.Parameters.AddWithValue("actor", Guid.CreateVersion7());
+
+ return (long)(await command.ExecuteScalarAsync())!;
+ }
+
+ /// Sequences a fresh reader can currently see, i.e. committed ones.
+ private async Task> VisibleSequencesAsync(Guid vaultId)
+ {
+ await using var context = fixture.CreateContext();
+
+ return await context.VaultChanges
+ .Where(c => c.VaultId == vaultId)
+ .OrderBy(c => c.Sequence)
+ .Select(c => c.Sequence)
+ .ToListAsync()
+ ;
+ }
+
+ private async Task SeedVaultAsync()
+ {
+ await using var context = fixture.CreateContext();
+
+ var user = new UserAccount
+ {
+ Id = Guid.CreateVersion7(),
+ Issuer = "https://idp.example",
+ Subject = Guid.CreateVersion7().ToString(),
+ Status = UserStatus.Active,
+ CreatedAtUtc = Now,
+ UpdatedAtUtc = Now,
+ };
+
+ var vault = new Vault
+ {
+ Id = Guid.CreateVersion7(),
+ Name = "Personal",
+ OwnerKind = VaultOwnerKind.Personal,
+ OwnerUserId = user.Id,
+ KeyGeneration = 1,
+ CreatedAtUtc = Now,
+ UpdatedAtUtc = Now,
+ };
+
+ context.Users.Add(user);
+ context.Vaults.Add(vault);
+ await context.SaveChangesAsync();
+
+ return vault.Id;
+ }
+}
diff --git a/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs b/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs
index 8748cf1..9fbe9d3 100644
--- a/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs
+++ b/tests/DodoSSH.Infrastructure.Tests/SchemaConstraintTests.cs
@@ -398,7 +398,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
var first = NewChange(vault.Id);
var second = NewChange(vault.Id);
- context.SyncChanges.AddRange(first, second);
+ context.VaultChanges.AddRange(first, second);
await context.SaveChangesAsync();
first.Sequence.ShouldBeGreaterThan(0);
@@ -511,7 +511,7 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
return vault;
}
- private static Host NewHost(Vault vault, bool relayEnabled, string? hostname, int? port) => new()
+ private static SshHost NewHost(Vault vault, bool relayEnabled, string? hostname, int? port) => new()
{
Id = Guid.CreateVersion7(),
VaultId = vault.Id,
@@ -558,10 +558,10 @@ public sealed class SchemaConstraintTests(PostgresFixture fixture)
CreatedAtUtc = Now,
};
- private static SyncChange NewChange(Guid vaultId) => new()
+ private static VaultChange NewChange(Guid vaultId) => new()
{
VaultId = vaultId,
- EntityType = ChangeEntityType.Host,
+ EntityType = ChangeEntityType.SshHost,
EntityId = Guid.CreateVersion7(),
Operation = ChangeOperation.Upsert,
Revision = 1,