using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Features.Sync;
///
/// Everything about one item type that the shared write path cannot know.
///
///
///
/// The push path is the most dangerous code in the server: it holds the version check, the change-log
/// append, the receipt that makes a retry exactly-once, and the advisory lock that keeps the sequence in
/// commit order. Duplicating that per item type would mean ten copies of it by M5, and a fix applied to nine
/// of them. So it is written once over , and everything genuinely type-specific —
/// which table, which plaintext columns, what those columns must satisfy — arrives through here.
///
///
/// Deliberately not generic. A generic IItemKind<TItem> reads better in isolation and would
/// force every method on the write path to be generic too, including the async ones, for no benefit: the
/// path never needs the concrete type, only the shared shape. The downcast each implementation performs is
/// contained to one class per type and is guaranteed by construction, because the same class both creates
/// and queries the rows.
///
///
internal interface IItemKind
{
/// The type as the wire contract names it.
SyncEntityType WireType { get; }
/// The type as the change log names it.
///
/// Stated rather than cast. The two enums do agree numerically — and they have different member names
/// for the same value, Host against SshHost — but that alignment is hand-kept and nothing
/// in the type system defends it. Writing it out per kind means adding a type cannot silently file its
/// changes under another type's name.
///
ChangeEntityType ChangeType { get; }
/// Finds one item by id, across every vault, so a cross-vault id can be refused.
Task FindAsync(DodoDbContext database, Guid id, CancellationToken cancellationToken);
/// Loads the rows behind a set of change-log entries, scoped to one vault.
Task> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken);
/// Creates an empty row of this type and tracks it.
IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId);
/// Rejects plaintext fields this type may not carry.
bool ValidateFields(SyncPlaintextFields fields, out string error);
/// Copies this type's plaintext columns out of the request.
void ApplyFields(IVaultItem item, SyncPlaintextFields fields);
/// Clears plaintext columns that must not outlive the item.
void ClearFieldsOnDelete(IVaultItem item);
/// Reads this type's plaintext columns back for a pull, or null when it has none.
SyncPlaintextFields? Hydrate(IVaultItem item);
}
/// The item types this server can synchronise, by wire type.
internal static class ItemKinds
{
private static readonly Dictionary Supported =
new[]
{
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
new HostGroupKind(), new SnippetKind(),
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
}.ToDictionary(kind => kind.WireType);
/// The kind for a wire type, or null when this server does not synchronise it yet.
///
/// Null rather than a throw. Every entity type in the contract is reachable by a newer client, and the
/// push endpoint answers per operation — so an unsupported type has to become one Invalid result
/// with a reason, not a failed batch that also rejects the operations this server did understand.
///
internal static IItemKind? For(SyncEntityType type) =>
Supported.TryGetValue(type, out var kind) ? kind : null;
}
/// Hosts: the one item type with a deliberate plaintext concession.
internal sealed class HostKind : IItemKind
{
///
public SyncEntityType WireType => SyncEntityType.Host;
///
public ChangeEntityType ChangeType => ChangeEntityType.SshHost;
///
public async Task FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.Hosts.SingleOrDefaultAsync(h => h.Id == id, cancellationToken)
.ConfigureAwait(false);
///
public async Task> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.Hosts
.Where(h => h.VaultId == vaultId && ids.Contains(h.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
///
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var host = new SshHost { Id = id, VaultId = vaultId };
database.Hosts.Add(host);
return host;
}
///
/// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a constraint
/// violation surfacing as a 500.
///
///
///
/// The group check comes first, ahead of the relay branch, and that placement is the point: the
/// relay branch returns early on its happy path, so a check placed after it would apply to non-relay
/// hosts only — leaving the one field this refusal exists for reachable by exactly the hosts most likely
/// to carry it.
///
///
/// SyncPlaintextFields.GroupId is part of a frozen wire contract and cannot be removed from it, so
/// refusing it here is what actually keeps the value out of the database. The column it used to be copied
/// into was dropped when groups landed; see for why membership travels inside
/// the payload instead.
///
///
///
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.GroupId is not null)
{
error = "A host's group is inside its encrypted payload; the server does not store one.";
return false;
}
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;
}
///
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
ArgumentNullException.ThrowIfNull(fields);
var host = (SshHost)item;
host.RelayEnabled = fields.RelayEnabled;
host.Hostname = fields.RelayEnabled ? fields.Hostname : null;
host.Port = fields.RelayEnabled ? fields.Port : null;
}
///
/// The address goes with the item. Leaving it would keep the server able to resolve a host the user
/// believes they deleted.
///
///
public void ClearFieldsOnDelete(IVaultItem item)
{
var host = (SshHost)item;
host.RelayEnabled = false;
host.Hostname = null;
host.Port = null;
}
///
public SyncPlaintextFields? Hydrate(IVaultItem item)
{
var host = (SshHost)item;
return new SyncPlaintextFields(
RelayEnabled: host.RelayEnabled,
Hostname: host.Hostname,
Port: host.Port);
}
}
/// SSH keys: ciphertext and nothing else.
internal sealed class SshKeyKind : IItemKind
{
///
public SyncEntityType WireType => SyncEntityType.SshKey;
///
public ChangeEntityType ChangeType => ChangeEntityType.SshKey;
///
public async Task FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.SshKeys.SingleOrDefaultAsync(k => k.Id == id, cancellationToken)
.ConfigureAwait(false);
///
public async Task> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.SshKeys
.Where(k => k.VaultId == vaultId && ids.Contains(k.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
///
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var key = new VaultSshKey { Id = id, VaultId = vaultId };
database.SshKeys.Add(key);
return key;
}
///
/// Refuses the relay columns outright.
///
///
/// A key is not something the server dials, so an address on one is either a client bug or an attempt to
/// get the server to store something it has no reason to hold. Refused with a reason rather than
/// silently dropped, because a client that thinks it is storing a field and is not will eventually be
/// surprised by its absence.
///
///
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "An SSH key has no relay target; relay fields may only be set on a host.";
return false;
}
return true;
}
///
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
ArgumentNullException.ThrowIfNull(fields);
// Only the fingerprint, and only because the contract reserved it. Everything that identifies the
// key to a person — its label, its comment — is inside the ciphertext.
((VaultSshKey)item).PublicKeyFingerprint = fields.PublicKeyFingerprint;
}
///
public void ClearFieldsOnDelete(IVaultItem item) =>
((VaultSshKey)item).PublicKeyFingerprint = null;
///
/// Null rather than an all-defaults instance, and the difference is visible on the wire: a
/// SyncPlaintextFields with nothing set still serialises relayEnabled: false, which
/// invites a reader to believe this type has a relay setting that happens to be off. It has none.
///
///
public SyncPlaintextFields? Hydrate(IVaultItem item) =>
((VaultSshKey)item).PublicKeyFingerprint is { } fingerprint
? new SyncPlaintextFields(PublicKeyFingerprint: fingerprint)
: null;
}
/// Credentials: an envelope and nothing else.
///
/// As strict as a kind gets about plaintext — is the other one — and the
/// reason is not symmetry. A key at least has a fingerprint that is public by nature; a password has no part
/// that is safe to expose, so this kind accepts no plaintext fields at all and hydrates none.
///
internal sealed class CredentialKind : IItemKind
{
///
public SyncEntityType WireType => SyncEntityType.Credential;
///
public ChangeEntityType ChangeType => ChangeEntityType.Credential;
///
public async Task FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.Credentials.SingleOrDefaultAsync(c => c.Id == id, cancellationToken)
.ConfigureAwait(false);
///
public async Task> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.Credentials
.Where(c => c.VaultId == vaultId && ids.Contains(c.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
///
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var credential = new VaultCredential { Id = id, VaultId = vaultId };
database.Credentials.Add(credential);
return credential;
}
///
/// Refuses every plaintext field there is.
///
///
/// Refused with a reason rather than silently dropped, so a client that believes it is storing something
/// finds out now rather than when the field turns out to be missing.
///
///
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A credential has no relay target; relay fields may only be set on a host.";
return false;
}
if (fields.PublicKeyFingerprint is not null)
{
error = "A credential has no public key.";
return false;
}
return true;
}
/// Nothing to copy: this type has no plaintext columns to copy anything into.
///
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
///
public void ClearFieldsOnDelete(IVaultItem item)
{
}
///
/// Always null, which is a stronger statement than an empty record: this type has no plaintext columns,
/// so there is nothing a pull could hydrate even in principle.
///
///
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// Known host keys: an envelope and nothing else.
///
/// As strict as about plaintext, for a reason that is about aggregation rather
/// than secrecy. A fingerprint is published so it can be compared and an address may already sit in a
/// relay-enabled host's columns — but the set of endpoints one user has approved is a map of their estate,
/// and this server has nothing to do with it.
///
internal sealed class KnownHostKeyKind : IItemKind
{
///
public SyncEntityType WireType => SyncEntityType.KnownHostKey;
///
public ChangeEntityType ChangeType => ChangeEntityType.KnownHostKey;
///
public async Task FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.KnownHostKeys.SingleOrDefaultAsync(k => k.Id == id, cancellationToken)
.ConfigureAwait(false);
///
public async Task> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.KnownHostKeys
.Where(k => k.VaultId == vaultId && ids.Contains(k.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
///
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var knownHost = new VaultKnownHostKey { Id = id, VaultId = vaultId };
database.KnownHostKeys.Add(knownHost);
return knownHost;
}
///
/// Refuses every plaintext field there is.
///
///
/// The relay fields are refused although this type is the one that does hold an address, and
/// that is the point: the address belongs in the ciphertext. A client sending it here is either confused
/// or trying to get the server to keep a list it has no business keeping, and either way it should be
/// told rather than have the value quietly dropped.
///
///
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A known host key is not something the server dials; its address stays encrypted.";
return false;
}
if (fields.PublicKeyFingerprint is not null)
{
error = "A known host key's fingerprint stays inside its payload.";
return false;
}
return true;
}
/// Nothing to copy: this type has no plaintext columns to copy anything into.
///
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
///
public void ClearFieldsOnDelete(IVaultItem item)
{
}
///
/// Always null, as for a credential: this type has no plaintext columns, so there is nothing a pull could
/// hydrate even in principle.
///
///
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// Host groups: an envelope and nothing else.
///
/// The kind that closes a hole rather than opening one. SyncPlaintextFields has carried a
/// GroupId since the contract was frozen and used to copy it into a column;
/// nothing ever sent one, and now nothing may. The group itself arrives here as ciphertext with no name the
/// server can read, which is the same answer gives for the same reason.
///
internal sealed class HostGroupKind : IItemKind
{
///
public SyncEntityType WireType => SyncEntityType.HostGroup;
///
public ChangeEntityType ChangeType => ChangeEntityType.HostGroup;
///
public async Task FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.HostGroups.SingleOrDefaultAsync(g => g.Id == id, cancellationToken)
.ConfigureAwait(false);
///
public async Task> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.HostGroups
.Where(g => g.VaultId == vaultId && ids.Contains(g.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
///
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var group = new VaultHostGroup { Id = id, VaultId = vaultId };
database.HostGroups.Add(group);
return group;
}
///
/// Refuses every plaintext field there is, including the one named after this type.
///
///
/// A GroupId on a group would be a parent pointer, and groups are flat — see
/// for why nesting merged by a scalar three-way merge can produce a cycle
/// nothing is able to repair. Refusing it here means a client that grows a tree cannot store one by
/// accident.
///
///
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A host group is not something the server dials.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null)
{
error = "Host groups are flat, and a group's name is inside its payload.";
return false;
}
if (fields.PublicKeyFingerprint is not null)
{
error = "A host group has no public key.";
return false;
}
return true;
}
/// Nothing to copy: this type has no plaintext columns to copy anything into.
///
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
///
public void ClearFieldsOnDelete(IVaultItem item)
{
}
///
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// Snippets: an envelope and nothing else.
///
/// A label column here would sort a list this server never draws, and the commands beside that label describe
/// the estate as precisely as a list of hostnames would. So this kind is as strict as
/// , and for the aggregation reason rather than the secrecy one.
///
internal sealed class SnippetKind : IItemKind
{
///
public SyncEntityType WireType => SyncEntityType.Snippet;
///
public ChangeEntityType ChangeType => ChangeEntityType.Snippet;
///
public async Task FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.Snippets.SingleOrDefaultAsync(s => s.Id == id, cancellationToken)
.ConfigureAwait(false);
///
public async Task> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.Snippets
.Where(s => s.VaultId == vaultId && ids.Contains(s.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
///
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var snippet = new VaultSnippet { Id = id, VaultId = vaultId };
database.Snippets.Add(snippet);
return snippet;
}
/// Refuses every plaintext field there is.
///
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A snippet is not something the server dials.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "A snippet's contents, including anything it is scoped to, stay inside its payload.";
return false;
}
if (fields.PublicKeyFingerprint is not null)
{
error = "A snippet has no public key.";
return false;
}
return true;
}
/// Nothing to copy: this type has no plaintext columns to copy anything into.
///
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
///
public void ClearFieldsOnDelete(IVaultItem item)
{
}
///
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// Connection log entries: an envelope and nothing else.
///
///
/// The strictest kind here, and the one where a plaintext column would have been most tempting: a
/// started_at would let this server order and prune a log without any client's help. It gets none,
/// because a timestamp column on this table is a record of when each user works, and the times are the
/// interesting part of a connection log even when the hostnames are sealed.
///
///
/// The server cannot enforce write-once, and does not pretend to. That an entry is created and never
/// updated is a client rule — see — and the shared write path would
/// accept an upsert with a correct expectedVersion like any other. Adding a refusal here would be a
/// guarantee about payload semantics this server cannot read.
///
///
internal sealed class ConnectionLogEntryKind : IItemKind
{
///
public SyncEntityType WireType => SyncEntityType.ConnectionLogEntry;
///
public ChangeEntityType ChangeType => ChangeEntityType.ConnectionLogEntry;
///
public async Task FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.ConnectionLog.SingleOrDefaultAsync(e => e.Id == id, cancellationToken)
.ConfigureAwait(false);
///
public async Task> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.ConnectionLog
.Where(e => e.VaultId == vaultId && ids.Contains(e.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
///
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var entry = new VaultConnectionLogEntry { Id = id, VaultId = vaultId };
database.ConnectionLog.Add(entry);
return entry;
}
/// Refuses every plaintext field there is.
///
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A log entry is not something the server dials; what was connected to stays encrypted.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "A log entry names what it is about inside its payload.";
return false;
}
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
{
error = "A log entry carries no plaintext fields at all.";
return false;
}
return true;
}
/// Nothing to copy: this type has no plaintext columns to copy anything into.
///
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
///
public void ClearFieldsOnDelete(IVaultItem item)
{
}
///
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// Activity log entries: an envelope and nothing else.
///
/// As strict as . SyncPlaintextFields.Kind exists and would fit
/// "which sort of item this entry is about" exactly, which is why it is refused by name: a column recording
/// that a user created four SSH keys last Tuesday is a description of the keychain, assembled from facts
/// that each look harmless.
///
internal sealed class ActivityLogEntryKind : IItemKind
{
///
public SyncEntityType WireType => SyncEntityType.ActivityLogEntry;
///
public ChangeEntityType ChangeType => ChangeEntityType.ActivityLogEntry;
///
public async Task FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.ActivityLog.SingleOrDefaultAsync(e => e.Id == id, cancellationToken)
.ConfigureAwait(false);
///
public async Task> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.ActivityLog
.Where(e => e.VaultId == vaultId && ids.Contains(e.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
///
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var entry = new VaultActivityLogEntry { Id = id, VaultId = vaultId };
database.ActivityLog.Add(entry);
return entry;
}
/// Refuses every plaintext field there is.
///
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A log entry is not something the server dials.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "Which item a log entry is about stays inside its payload.";
return false;
}
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
{
error = "A log entry carries no plaintext fields at all.";
return false;
}
return true;
}
/// Nothing to copy: this type has no plaintext columns to copy anything into.
///
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
///
public void ClearFieldsOnDelete(IVaultItem item)
{
}
///
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
/// Object stores: an envelope and nothing else.
///
/// As strict as , because it holds the same class of thing. A secret access key
/// is a password; the endpoint beside it is, for everybody self-hosting, an address on their own network. The
/// relay does not dial a bucket, so ADR 0004's one concession has no analogue here and there is nothing to
/// weigh.
///
internal sealed class ObjectStoreKind : IItemKind
{
///
public SyncEntityType WireType => SyncEntityType.ObjectStore;
///
public ChangeEntityType ChangeType => ChangeEntityType.ObjectStore;
///
public async Task FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.ObjectStores.SingleOrDefaultAsync(o => o.Id == id, cancellationToken)
.ConfigureAwait(false);
///
public async Task> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.ObjectStores
.Where(o => o.VaultId == vaultId && ids.Contains(o.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
///
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var store = new VaultObjectStore { Id = id, VaultId = vaultId };
database.ObjectStores.Add(store);
return store;
}
/// Refuses every plaintext field there is.
///
/// The relay fields are refused although this type does hold an address, exactly as they are for
/// a pinned host key: the address belongs in the ciphertext, and a client sending it here is either
/// confused or trying to get the server to keep a list of where its users store data.
///
///
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A bucket is not something the server dials; its endpoint stays encrypted.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "A bucket's contents are inside its payload.";
return false;
}
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
{
error = "A bucket carries no plaintext fields at all.";
return false;
}
return true;
}
/// Nothing to copy: this type has no plaintext columns to copy anything into.
///
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
///
public void ClearFieldsOnDelete(IVaultItem item)
{
}
///
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}