diff --git a/docs/adding-hosts-on-the-phone.md b/docs/adding-hosts-on-the-phone.md
index f472e82..cb057dd 100644
--- a/docs/adding-hosts-on-the-phone.md
+++ b/docs/adding-hosts-on-the-phone.md
@@ -4,7 +4,24 @@ The phone can read a keychain and connect through it. It cannot put anything in
plan for the change that fixes that, and it is written to be picked up cold — the decisions, the reasons, the
ordered work, and the traps that are already known.
-> **Status: planned, not started.** Nothing below is built. The only thing in the tree is this file.
+> **Status: steps 1–3 built, 4–6 not started.** The domain is done and the phone has not been touched. Each
+> built step compiles with the whole suite green, which is the rule the ordering below sets.
+>
+> | Step | State | Notes |
+> | --- | --- | --- |
+> | 1. `HostGroupSecret` grows | **Done** | Five fields, a version rule the codec did not have, a byte pin, and the "groups are flat" prose rewritten in all four places it appeared. |
+> | 2. The `Tag` item kind | **Done** | Secret, codec, merge, cipher, repository, both registries, EF entity and the generated `AddTagItem` migration. |
+> | 3. `HostSecret` grows, `Port` goes nullable | **Done** | `TagSet`, `TagIds`, `Port` as `int?`, `AsksForPassword`, both schema versions, and `HostInheritance` — the resolver. |
+> | 4. The shared view model | Not started | The five port call sites already route through the resolver; the rest of the list below does not. |
+> | 5. The phone | Not started | |
+> | 6. Tests and false prose | Partly done as it went | The guards steps 1–3 tripped are fixed. `docs/design-import-gaps.md` and the three phone files are untouched. |
+>
+> **One decision was taken that this plan did not specify.** "Three states where there were two" is four, not
+> three: a host can bind a key, bind a credential, be pinned to a typed password, or take its group's answer,
+> and two nullable ids express three of those. Naming neither id now means *inherit*, so
+> `HostSecret.AsksForPassword` was added to say "a typed password, even under a group that lends a key" out
+> loud. Nothing stored changed meaning — no group could lend a binding before this build, so every existing
+> host resolves exactly as it did.
## What was asked for
@@ -69,18 +86,19 @@ false prose and must be rewritten to say what replaced the argument, not deleted
## What the payload becomes
```
-HostSecret HostGroupSecret
- Label Label
- Hostname ParentId ← new, optional
- Port int? — null inherits DefaultPort ← new
- Username null inherits DefaultUsername ← new
- Notes DefaultSshKeyId ← new
- JumpHostIds DefaultCredentialId ← new
+HostSecret HostGroupSecret
+ Label Label
+ Hostname ParentId ← new, optional
+ Port int? — null inherits DefaultPort ← new
+ Username null inherits, "" none DefaultUsername ← new
+ Notes DefaultSshKeyId ← new
+ JumpHostIds DefaultCredentialId ← new
Options
- SshKeyId null inherits TagSecret
- CredentialId null inherits Label
- GroupId null = ungrouped
- TagIds ← new
+ SshKeyId null inherits TagSecret
+ CredentialId null inherits Label
+ AsksForPassword ← new, true only
+ GroupId null = ungrouped
+ TagIds ← new
RelayEnabled
```
@@ -92,10 +110,18 @@ after the chain runs out.
`SshKeyId` and `CredentialId` both null currently *means* "ask for a password each time" — a decision, not an
absence. `AuthenticationChoice`'s own remark argues at length that the two must never be conflated.
-Inheritance adds a third state, so the picker needs an explicit **"Inherit from group"** entry beside
-**"Password (ask each time)"**, and `Bound(...)` needs to distinguish them. `Username` has the same problem:
-null means "no username" today and is refused at connect; it has to come to mean "inherit", with "no username"
-still reachable and still refused.
+Inheritance adds a state, so the picker needs an explicit **"Inherit from group"** entry beside **"Password
+(ask each time)"**, and `Bound(...)` needs to distinguish them. `Username` has the same problem: null means
+"no username" today and is refused at connect; it has to come to mean "inherit", with "no username" still
+reachable and still refused.
+
+**Built as four states, not three, because two nullable ids only carry three.** Key, credential, typed
+password, inherit — and naming neither id was the third and is now the fourth. `HostSecret.AsksForPassword`
+carries the difference: null is "not stated", which walks the chain and lands on a typed password if the
+chain lends nothing, and `true` is "a typed password, even under a group that lends a key". Only `true` is
+ever written, and a decoded `false` folds back to null, so a host that never touched the field encodes
+exactly as it did before it existed. `Username` needed no field — an empty string is "no username" and null
+is "inherit". `Port` needed none either: there is no "explicitly no port", only 22 at the end of the chain.
Mutual exclusion moves with it. `HostSecret.TryValidate` enforces "a key or a credential, never both" per
record; a host naming a credential under a group naming a key is two individually valid records that resolve to
diff --git a/src/DodoSSH.Api/Features/Sync/ItemKinds.cs b/src/DodoSSH.Api/Features/Sync/ItemKinds.cs
index 44bc757..867f83f 100644
--- a/src/DodoSSH.Api/Features/Sync/ItemKinds.cs
+++ b/src/DodoSSH.Api/Features/Sync/ItemKinds.cs
@@ -71,7 +71,7 @@ internal static class ItemKinds
new[]
{
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
- new HostGroupKind(), new SnippetKind(),
+ new HostGroupKind(), new TagKind(), new SnippetKind(),
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
}.ToDictionary(kind => kind.WireType);
@@ -557,13 +557,17 @@ internal sealed class HostGroupKind : IItemKind
}
///
- /// Refuses every plaintext field there is, including the one named after this type.
+ /// Refuses every plaintext field there is, including the two that would describe this type's own place
+ /// in a tree.
///
///
- /// 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.
+ /// Groups nest, and the pointer that nests them is still refused here. The refusal used to mean
+ /// "there is no such thing as a parent"; it now means "the parent is not the server's to hold". A
+ /// ParentId or GroupId column on this table would let the operator reconstruct the shape of
+ /// every user's estate — how many groupings, how deep, which under which — which is precisely the
+ /// disclosure ADR 0004 refuses everywhere except the one address the relay cannot dial without. The
+ /// parent travels inside the envelope like the name beside it, and the client is the only thing that can
+ /// read either.
///
///
public bool ValidateFields(SyncPlaintextFields fields, out string error)
@@ -580,7 +584,7 @@ internal sealed class HostGroupKind : IItemKind
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.";
+ error = "A host group's name and its place in the tree are inside its payload.";
return false;
}
@@ -608,6 +612,108 @@ internal sealed class HostGroupKind : IItemKind
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
+/// Tags: an envelope and nothing else.
+///
+/// 's answer to the same question, at higher stakes. A group name says how one
+/// user files their machines; a tag name says what the machines are, and one tag is meant to span twenty of
+/// them — so a plaintext label here would hand the operator a labelled map of every estate on the server, to
+/// sort a list nothing server-side draws.
+///
+internal sealed class TagKind : IItemKind
+{
+ ///
+ public SyncEntityType WireType => SyncEntityType.Tag;
+
+ ///
+ public ChangeEntityType ChangeType => ChangeEntityType.Tag;
+
+ ///
+ public async Task FindAsync(
+ DodoDbContext database,
+ Guid id,
+ CancellationToken cancellationToken) =>
+ await database.Tags.SingleOrDefaultAsync(t => t.Id == id, cancellationToken)
+ .ConfigureAwait(false);
+
+ ///
+ public async Task> LoadAsync(
+ DodoDbContext database,
+ Guid vaultId,
+ Guid[] ids,
+ CancellationToken cancellationToken)
+ {
+ var rows = await database.Tags
+ .Where(t => t.VaultId == vaultId && ids.Contains(t.Id))
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
+ }
+
+ ///
+ public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
+ {
+ var tag = new VaultTag { Id = id, VaultId = vaultId };
+
+ database.Tags.Add(tag);
+
+ return tag;
+ }
+
+ ///
+ /// Refuses every plaintext field there is, including the one a join table would have reached for.
+ ///
+ ///
+ /// RelatedId matters more here than for any other kind. It is the obvious place to put "the
+ /// host this tag is on", which is exactly what SyncEntityType.HostTag reserves a slot for and what
+ /// this design decided not to build — membership is a set inside each host's payload. A future client
+ /// reaching for the field would be storing the host-to-tag graph in the clear, one row at a time, which
+ /// is the aggregation this kind exists to refuse. SyncPlaintextFields is frozen so the field
+ /// cannot be removed; refusing it is the only place the decision can be enforced.
+ ///
+ ///
+ 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 tag 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 tag's name is inside its payload, and which hosts wear it is inside theirs.";
+ return false;
+ }
+
+ if (fields.PublicKeyFingerprint is not null)
+ {
+ error = "A tag 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
diff --git a/src/DodoSSH.Client.Domain/HostGroupSecret.cs b/src/DodoSSH.Client.Domain/HostGroupSecret.cs
index 0dff237..be58717 100644
--- a/src/DodoSSH.Client.Domain/HostGroupSecret.cs
+++ b/src/DodoSSH.Client.Domain/HostGroupSecret.cs
@@ -3,24 +3,36 @@ using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
///
-/// A folder hosts can be filed under, decrypted.
+/// A folder hosts can be filed under, decrypted, and the defaults they inherit from it.
///
///
///
-/// One field, which makes this the smallest secret in the vault, and the small size is the feature. A group
-/// is a heading in a sidebar; everything else somebody might want from it — which hosts are in it, where it
-/// sits in a tree, what colour it is — was considered and left out, each for its own reason.
+/// A group is a heading in a sidebar and a place to say a thing once. Both halves are here: a
+/// so headings nest, and four Default fields a host under this group falls
+/// back to when it leaves the matching field unset.
///
///
/// No member list. Membership is a on each host, so filing two
/// different hosts into one group on two machines is two writes to two items. Held here it would be two
-/// writes to one item, and has no set merge — the collision would resolve by one
-/// side winning outright and the other host silently leaving the group it was just put in.
+/// writes to one item, and while could now resolve that key by key, the
+/// pointer on the host is still the better place: it is one write to the item the user just edited, and it
+/// cannot disagree with itself about which group a host is in.
///
///
-/// No parent. Groups are flat. Two clients can each re-parent A under B and B under A while offline,
-/// and a scalar merge accepts both: the result is a cycle that no reader can draw and that the server cannot
-/// even see, because it is inside the payload. One level of nesting is not worth a state with no repair path.
+/// Groups nest, and the cycle is contained rather than prevented. This is a reversal, and the
+/// argument it reverses was real: two clients can each re-parent A under B and B under A while offline, a
+/// scalar merge accepts both, and the result is a cycle no reader can draw and the server cannot even see,
+/// because it is inside the payload. What changed is that a merge is not the only place a cycle has to
+/// survive. Inheritance means the chain is walked at connect time, so the answer had to be a walk that
+/// terminates regardless — the resolver carries a visited set and stops at a repeat. A cycle therefore
+/// degrades to a group that reads as a root: flat headings, defaults unresolved past that point, and
+/// clearing the parent in the editor is the repair. Given a walk that already had to be cycle-safe,
+/// refusing to nest bought nothing.
+///
+///
+/// The editor additionally refuses a parent that is already a descendant, which stops a cycle being made
+/// on this machine. That is a convenience, not the guarantee — the guarantee is the visited set, because a
+/// cycle assembled from two offline edits was never offered to an editor at all.
///
///
public sealed record HostGroupSecret : IVaultSecret
@@ -28,11 +40,85 @@ public sealed record HostGroupSecret : IVaultSecret
/// What the group is called. The only name it has anywhere.
public required string Label { get; init; }
+ ///
+ /// The group this one sits under, or null for a root.
+ ///
+ ///
+ ///
+ /// Points upwards for the same reason does: re-parenting two groups
+ /// under one parent on two machines is then two writes to two items rather than two writes to one.
+ ///
+ ///
+ /// The reference may dangle, and a dangling one is a root — the group whose parent was deleted on
+ /// another machine appears at the top level rather than disappearing. Same handling as a host whose group
+ /// is gone, and for the same reason: preventing it would mean one delete rewriting every item that named
+ /// the deleted thing.
+ ///
+ ///
+ /// Inside the payload. SyncPlaintextFields has a ParentId and the server refuses to accept
+ /// one, deliberately: what a plaintext parent hands over is the shape of the user's estate, which is the
+ /// same disclosure a plaintext GroupId would have been. See ADR 0004.
+ ///
+ ///
+ public Guid? ParentId { get; init; }
+
+ ///
+ /// The TCP port hosts in this group use when they do not pin one, or null for no default.
+ ///
+ ///
+ /// Inherited, not copied. A host created here is not stamped with this value; it is left unset and reads
+ /// through at connect time, so changing this changes every host that never overrode it. Copying at
+ /// creation was the alternative and it makes a group a one-shot template — every host ever created under
+ /// it stays pinned to whatever the default happened to be that day.
+ ///
+ public int? DefaultPort { get; init; }
+
+ ///
+ /// The login user hosts in this group use when they do not pin one, or null for no default.
+ ///
+ ///
+ public string? DefaultUsername { get; init; }
+
+ ///
+ /// The vault SSH key hosts in this group authenticate with when they bind nothing, or null for no
+ /// default.
+ ///
+ ///
+ ///
+ /// Same dangling-reference story as , and the same refusal to fall back
+ /// to a password when it cannot be resolved. One extra consequence: a key named only here is named by no
+ /// host at all, so anything counting the hosts bound to a key has to walk groups or it will report a key
+ /// as unused and then refuse every host under this group at connect time.
+ ///
+ ///
+ /// Mutually exclusive with on this record, exactly as the two
+ /// are on a host. That is necessary and not sufficient: a host naming a credential under a group naming a
+ /// key is two individually valid records, so the resolver enforces the same exclusion again across the
+ /// chain, host over group, and never returns both.
+ ///
+ ///
+ public Guid? DefaultSshKeyId { get; init; }
+
+ ///
+ /// The vault credential hosts in this group authenticate with when they bind nothing, or null for no
+ /// default.
+ ///
+ ///
+ public Guid? DefaultCredentialId { get; init; }
+
/// Whether this is storable, and why not if it is not.
///
- /// A blank name is refused rather than defaulted. A group is only ever a heading, so a nameless one is
+ ///
+ /// A blank name is refused rather than defaulted. A group is a heading, so a nameless one is
/// indistinguishable from the ungrouped heading it would sit next to — and a user cannot select what they
/// cannot tell apart.
+ ///
+ ///
+ /// A group's own id is not knowable here, so "a group is not its own parent" cannot be checked at
+ /// this layer: the id belongs to the item, and this record is only the payload inside it. A self-parent
+ /// is therefore caught twice further out — refused by the editor, and survived by the resolver's visited
+ /// set, which is the same machinery that has to contain a longer cycle anyway.
+ ///
///
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
@@ -42,6 +128,40 @@ public sealed record HostGroupSecret : IVaultSecret
return false;
}
+ if (ParentId == Guid.Empty)
+ {
+ // An empty id is not "no parent" — that is null. It is a reference that can never resolve, and a
+ // group holding one would read as a root while claiming to be nested.
+ reason = "A parent reference cannot be an empty id; use no parent instead.";
+ return false;
+ }
+
+ // Null is "no default port" and passes this comparison, which is the wanted behaviour and is
+ // silent about it: `is < 1 or > 65535` is false for a null int?.
+ if (DefaultPort is < 1 or > 65535)
+ {
+ reason = $"A default port must be between 1 and 65535, not {DefaultPort}.";
+ return false;
+ }
+
+ if (DefaultSshKeyId == Guid.Empty)
+ {
+ reason = "An SSH key reference cannot be an empty id; use no default key instead.";
+ return false;
+ }
+
+ if (DefaultCredentialId == Guid.Empty)
+ {
+ reason = "A credential reference cannot be an empty id; use no default credential instead.";
+ return false;
+ }
+
+ if (DefaultSshKeyId is not null && DefaultCredentialId is not null)
+ {
+ reason = "A group defaults to a key or to a credential, not both.";
+ return false;
+ }
+
reason = null;
return true;
}
diff --git a/src/DodoSSH.Client.Domain/HostGroupSecretCodec.cs b/src/DodoSSH.Client.Domain/HostGroupSecretCodec.cs
index a23f253..267d63c 100644
--- a/src/DodoSSH.Client.Domain/HostGroupSecretCodec.cs
+++ b/src/DodoSSH.Client.Domain/HostGroupSecretCodec.cs
@@ -17,15 +17,29 @@ public sealed record HostGroupSecretDocument(HostGroupSecret Group, int SchemaVe
/// Encodes and decodes the plaintext inside a group item's encrypted payload.
///
///
-/// Mirrors , for the same reasons and with the same guarantees. One field
-/// makes this look like ceremony around a string, and it is not: what the JSON envelope buys is a schema
-/// version, which is what lets a later build add a field without every older client silently dropping it on
-/// the next edit. See .
+/// Mirrors , for the same reasons and with the same guarantees. What the
+/// JSON envelope buys is a schema version, which is what lets a later build add a field without every older
+/// client silently dropping it on the next edit — and this is the build that spent it, five fields at once.
+/// See .
///
public static class HostGroupSecretCodec
{
- /// The schema version this build writes.
- public const int CurrentSchemaVersion = 1;
+ /// The first version, and the one a group with no newer field is still written at.
+ public const int BaseSchemaVersion = 1;
+
+ ///
+ /// The version that introduced and the four defaults.
+ ///
+ ///
+ /// One constant for five fields, because they arrive in the same build: no client exists that can read
+ /// some of them and not the others, so a version per field would draw a distinction nothing can observe.
+ /// is still written as a maximum, which is what has to stay true when the
+ /// sixth field arrives on its own.
+ ///
+ public const int ParentAndDefaultsSchemaVersion = 2;
+
+ /// The highest schema version this build can write.
+ public const int CurrentSchemaVersion = ParentAndDefaultsSchemaVersion;
/// Serialises a group to the bytes that get sealed.
/// The group is not valid for storage.
@@ -40,14 +54,55 @@ public static class HostGroupSecretCodec
var document = new HostGroupPayloadDocument
{
- SchemaVersion = CurrentSchemaVersion,
+ SchemaVersion = SchemaVersionFor(group),
Label = group.Label,
+ ParentId = group.ParentId,
+ DefaultPort = group.DefaultPort,
+ DefaultUsername = group.DefaultUsername,
+ DefaultSshKeyId = group.DefaultSshKeyId,
+ DefaultCredentialId = group.DefaultCredentialId,
};
return JsonSerializer.SerializeToUtf8Bytes(
document, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument);
}
+ ///
+ /// The lowest schema version that can represent this group without losing anything.
+ ///
+ ///
+ ///
+ /// Not simply , which is what this codec used to stamp
+ /// unconditionally — harmless while there was one field and one version, and not harmless from here on.
+ /// The version is what makes an older client treat an
+ /// item as read-only, so stamping the newest one regardless would mean that upgrading a single machine
+ /// and then renaming any group made that group uneditable on every machine still on the old
+ /// build. Emitting the lowest version that loses nothing confines that cost to the groups which actually
+ /// nest or actually carry a default.
+ ///
+ ///
+ /// It also means a group carrying none of the new fields encodes byte for byte as it did before they
+ /// existed, so adding them did not make every group in every vault look like a change to the sync
+ /// engine. has the same rule and it is the same argument; see
+ /// SchemaVersionFor there for why it is a maximum over the fields present rather than a ladder.
+ ///
+ ///
+ private static int SchemaVersionFor(HostGroupSecret group)
+ {
+ var version = BaseSchemaVersion;
+
+ if (group.ParentId is not null
+ || group.DefaultPort is not null
+ || group.DefaultUsername is not null
+ || group.DefaultSshKeyId is not null
+ || group.DefaultCredentialId is not null)
+ {
+ version = Math.Max(version, ParentAndDefaultsSchemaVersion);
+ }
+
+ return version;
+ }
+
/// Parses a decrypted payload.
///
public static bool TryDecode(
@@ -72,7 +127,15 @@ public static class HostGroupSecretCodec
return false;
}
- var candidate = new HostGroupSecret { Label = parsed.Label ?? string.Empty };
+ var candidate = new HostGroupSecret
+ {
+ Label = parsed.Label ?? string.Empty,
+ ParentId = parsed.ParentId,
+ DefaultPort = parsed.DefaultPort,
+ DefaultUsername = parsed.DefaultUsername,
+ DefaultSshKeyId = parsed.DefaultSshKeyId,
+ DefaultCredentialId = parsed.DefaultCredentialId,
+ };
if (!candidate.TryValidate(out _))
{
@@ -91,6 +154,26 @@ internal sealed class HostGroupPayloadDocument
public int SchemaVersion { get; set; }
public string? Label { get; set; }
+
+ ///
+ /// After , deliberately. Property order is the serialisation order, so appending
+ /// keeps the bytes for the one field that existed before these identical — and a null is omitted
+ /// entirely, which is what makes a flat group with no defaults encode exactly as it did before any of
+ /// this existed.
+ ///
+ public Guid? ParentId { get; set; }
+
+ ///
+ public int? DefaultPort { get; set; }
+
+ ///
+ public string? DefaultUsername { get; set; }
+
+ ///
+ public Guid? DefaultSshKeyId { get; set; }
+
+ ///
+ public Guid? DefaultCredentialId { get; set; }
}
[JsonSourceGenerationOptions(
diff --git a/src/DodoSSH.Client.Domain/HostGroupSecretMerge.cs b/src/DodoSSH.Client.Domain/HostGroupSecretMerge.cs
index 08a57be..d57e57f 100644
--- a/src/DodoSSH.Client.Domain/HostGroupSecretMerge.cs
+++ b/src/DodoSSH.Client.Domain/HostGroupSecretMerge.cs
@@ -1,3 +1,5 @@
+using System.Globalization;
+
namespace DodoSSH.Client.Domain;
/// The merged group, and everything that had to be overridden to produce it.
@@ -16,14 +18,22 @@ public sealed record HostGroupMergeResult(
///
///
///
-/// One scalar, so this is the simplest merge in the client and the only interesting thing about it is what it
-/// does not have to consider. Filing a host into a group does not write to the group, so two people
-/// organising the same vault at the same time never collide here — the only way to reach this code is for two
-/// people to rename the same group differently, which is a real disagreement and gets a conflict notice.
+/// Six scalars, no collections, which keeps this the simplest item merge in the client — and the
+/// interesting thing about it is still what it does not have to consider. Filing a host into a
+/// group does not write to the group, so two people organising the same vault at the same time never
+/// collide here.
///
///
-/// Nothing is redacted. A group name is the one thing a group has, and a notice saying only that "the name
-/// differed" would leave the user unable to tell which of their two names survived.
+/// The parent merges as a scalar, and that is what admits a cycle. Two clients re-parenting A under
+/// B and B under A while offline each produce a locally sensible group, and there is nothing here that can
+/// see the pair. Resolving it would mean a merge that reads the whole group list, which the merge does not
+/// have and should not grow — this layer resolves one item against one item. The cycle is contained where
+/// it is walked instead: see .
+///
+///
+/// Nothing is redacted. The name is the one thing a group has, and a notice saying only that "the name
+/// differed" would leave the user unable to tell which of their two names survived. The ids are shown for
+/// the same reason they are on a host — an id names a vault item, it is not the secret inside it.
///
///
public static class HostGroupSecretMerge
@@ -43,21 +53,123 @@ public static class HostGroupSecretMerge
var conflicts = new List();
- var merge = ThreeWayMerge.Scalar(
- ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal);
+ var merged = new HostGroupSecret
+ {
+ Label = Field(
+ nameof(HostGroupSecret.Label),
+ ancestor.Label,
+ local.Label,
+ remote.Label,
+ conflicts,
+ static label => label,
+ StringComparer.Ordinal),
+
+ ParentId = Field(
+ nameof(HostGroupSecret.ParentId),
+ ancestor.ParentId,
+ local.ParentId,
+ remote.ParentId,
+ conflicts,
+ static id => id?.ToString() ?? "no parent"),
+ };
+
+ return new HostGroupMergeResult(
+ WithDefaults(merged, ancestor, local, remote, conflicts), conflicts);
+ }
+
+ ///
+ /// Merges the four values hosts under this group fall back to.
+ ///
+ ///
+ ///
+ /// Split out for length, and they do belong together: each is a value a host reads through to when it
+ /// leaves its own field unset, and each merges as a plain scalar. The two ids can additionally end up
+ /// dangling, exactly as a host's can — handled where the reference is used, not here.
+ ///
+ ///
+ /// The exclusion between the key and the credential is not re-checked here, and it does not need
+ /// to be: neither side can hold both, and a scalar merge takes one value per field from one side or the
+ /// other, so it cannot manufacture a pair that was not offered. What it can manufacture is a group
+ /// defaulting to a key beneath which a host names a credential, and no item merge can see that — it is
+ /// the resolver's to enforce.
+ ///
+ ///
+ private static HostGroupSecret WithDefaults(
+ HostGroupSecret merged,
+ HostGroupSecret ancestor,
+ HostGroupSecret local,
+ HostGroupSecret remote,
+ List conflicts) =>
+ merged with
+ {
+ DefaultPort = Field(
+ nameof(HostGroupSecret.DefaultPort),
+ ancestor.DefaultPort,
+ local.DefaultPort,
+ remote.DefaultPort,
+ conflicts,
+ static port => port?.ToString(CultureInfo.InvariantCulture) ?? "no default port"),
+
+ DefaultUsername = Field(
+ nameof(HostGroupSecret.DefaultUsername),
+ ancestor.DefaultUsername,
+ local.DefaultUsername,
+ remote.DefaultUsername,
+ conflicts,
+ static username => username ?? "no default user",
+ StringComparer.Ordinal),
+
+ DefaultSshKeyId = Field(
+ nameof(HostGroupSecret.DefaultSshKeyId),
+ ancestor.DefaultSshKeyId,
+ local.DefaultSshKeyId,
+ remote.DefaultSshKeyId,
+ conflicts,
+ static id => id?.ToString() ?? "no default key"),
+
+ DefaultCredentialId = Field(
+ nameof(HostGroupSecret.DefaultCredentialId),
+ ancestor.DefaultCredentialId,
+ local.DefaultCredentialId,
+ remote.DefaultCredentialId,
+ conflicts,
+ static id => id?.ToString() ?? "no default credential"),
+ };
+
+ ///
+ ///
+ /// The local side always loses a scalar clash — see — so the discarded side
+ /// is fixed here rather than derived from the outcome.
+ ///
+ ///
+ /// The formatter is handed the discarded value even when that value is null, and the null-forgiving
+ /// operator says why that is safe: a conflicted merge always has a discarded value, so a null here is a
+ /// nullable field whose discarded value was "unset" rather than a missing one. Every field on this
+ /// record but the name has a name for its absence, and short-circuiting on null would mean none of them
+ /// could report it.
+ ///
+ ///
+ private static T Field(
+ string name,
+ T ancestor,
+ T local,
+ T remote,
+ List conflicts,
+ Func format,
+ IEqualityComparer? comparer = null)
+ {
+ var merge = ThreeWayMerge.Scalar(ancestor, local, remote, comparer);
if (merge.IsConflicted)
{
- // The local side always loses a scalar clash — see ThreeWayMerge — so the discarded side is
- // fixed here rather than derived from the outcome.
conflicts.Add(new HostFieldConflict(
- nameof(HostGroupSecret.Label),
+ name,
MergeSide.Local,
- merge.Value,
- merge.Discarded,
+ format(merge.Value),
+ format(merge.Discarded!),
DiscardedWasRemoval: false));
}
- return new HostGroupMergeResult(new HostGroupSecret { Label = merge.Value }, conflicts);
+ return merge.Value;
}
}
diff --git a/src/DodoSSH.Client.Domain/HostInheritance.cs b/src/DodoSSH.Client.Domain/HostInheritance.cs
new file mode 100644
index 0000000..1f2a86b
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/HostInheritance.cs
@@ -0,0 +1,217 @@
+using System.Runtime.InteropServices;
+
+namespace DodoSSH.Client.Domain;
+
+/// Which of the three ways a resolved host authenticates.
+public enum ResolvedBindingKind
+{
+ ///
+ /// A password typed at connect time. The terminal answer, reached either because nothing in the chain
+ /// bound anything or because the host said so outright.
+ ///
+ TypedPassword = 0,
+
+ /// A vault SSH key.
+ SshKey = 1,
+
+ /// A vault credential.
+ Credential = 2,
+}
+
+///
+/// One resolved value, and the group it came from.
+///
+/// The value's type.
+/// What the connect path should use.
+///
+/// The group that supplied it, or null when the host supplied it itself — which is also what the editor
+/// needs, because a value the host owns is text in a box and a value it inherits is a placeholder behind an
+/// empty one.
+///
+[StructLayout(LayoutKind.Auto)]
+public readonly record struct Inherited(T Value, Guid? FromGroupId)
+{
+ /// Whether this came from a group rather than from the host.
+ public bool IsInherited => FromGroupId is not null;
+}
+
+///
+/// How a host authenticates once its group chain has been consulted.
+///
+/// Which of the three ways.
+///
+/// The key or credential item id, or null for a typed password.
+///
+/// The group that supplied the binding, or null when the host did.
+[StructLayout(LayoutKind.Auto)]
+public readonly record struct ResolvedBinding(
+ ResolvedBindingKind Kind,
+ Guid? EntityId,
+ Guid? FromGroupId)
+{
+ /// A password typed at connect time, decided by the host itself.
+ public static ResolvedBinding TypedByTheHost { get; } =
+ new(ResolvedBindingKind.TypedPassword, null, null);
+
+ /// Whether this came from a group rather than from the host.
+ public bool IsInherited => FromGroupId is not null;
+}
+
+///
+/// Everything about a host that only makes sense once its groups have been read.
+///
+/// The port to dial.
+/// The user to log in as, or null when nothing supplied one.
+/// How to authenticate.
+public sealed record ResolvedHost(
+ Inherited Port,
+ Inherited Username,
+ ResolvedBinding Binding);
+
+///
+/// Resolves a host against the groups above it.
+///
+///
+///
+/// A host may leave its port, its username and its binding unset, in which case each is taken from the
+/// nearest group above it that states one — its own group, then that group's parent, and so on. Nothing is
+/// copied at creation, so changing a group changes every host beneath it that never overrode the field. See
+/// .
+///
+///
+/// Every walk carries a visited set, and that is load-bearing rather than defensive. Two clients can
+/// each re-parent A under B and B under A while offline; the merge resolves one item against one item and
+/// cannot see the pair, and the server cannot see it either because the pointer is inside the payload. With
+/// inheritance the chain is walked on the connect path, so an unguarded cycle is not an undrawable sidebar
+/// — it is a shell that never opens. Stopping at the first repeat degrades a cycle to a group that reads as
+/// a root: flat headings, defaults unresolved past that point, and clearing the parent in the editor is the
+/// repair.
+///
+///
+/// The key-or-credential exclusion is enforced here as well as on each record. A host naming a
+/// credential under a group naming a key is two individually valid items, so per-record validation cannot
+/// catch it. The nearest statement wins outright — host over group, nearer group over further — and this
+/// never returns both.
+///
+///
+/// Groups are handed in as a lookup rather than fetched, because this runs on the connect path and on every
+/// row of a list. The caller already holds the decrypted group list; a resolver that went back to a
+/// repository would decrypt the vault once per host drawn.
+///
+///
+public static class HostInheritance
+{
+ ///
+ /// Resolves one host.
+ ///
+ /// The host, as stored.
+ ///
+ /// Every group in the vaults being read, by item id. A missing id is a group deleted on another machine,
+ /// and the walk stops there — the host keeps whatever it had already resolved and falls back for the
+ /// rest, rather than failing.
+ ///
+ public static ResolvedHost Resolve(HostSecret host, IReadOnlyDictionary groups)
+ {
+ ArgumentNullException.ThrowIfNull(host);
+ ArgumentNullException.ThrowIfNull(groups);
+
+ Inherited? port = host.Port is { } pinned ? new Inherited(pinned, null) : null;
+
+ // Note which null is which. `host.Username is null` means the host stated nothing and the walk
+ // continues; an empty string is a statement — "no username" — and stops it, so a host under a group
+ // can opt out of the group's user. See HostSecret.Username.
+ Inherited? username = host.Username is not null
+ ? new Inherited(host.Username, null)
+ : null;
+
+ var binding = BindingOf(host);
+
+ foreach (var (groupId, group) in Chain(host.GroupId, groups))
+ {
+ port ??= group.DefaultPort is { } inheritedPort
+ ? new Inherited(inheritedPort, groupId)
+ : null;
+
+ username ??= group.DefaultUsername is not null
+ ? new Inherited(group.DefaultUsername, groupId)
+ : null;
+
+ binding ??= BindingOf(group, groupId);
+
+ if (port is not null && username is not null && binding is not null)
+ {
+ break;
+ }
+ }
+
+ return new ResolvedHost(
+ port ?? new Inherited(HostSecret.DefaultPort, null),
+ username ?? new Inherited(null, null),
+ binding ?? ResolvedBinding.TypedByTheHost);
+ }
+
+ ///
+ /// The groups above a host, nearest first, stopping at a repeat.
+ ///
+ ///
+ /// Public because the editor walks the same chain to draw its placeholders and the sidebar walks it to
+ /// draw headings, and three separate walks would be three places to forget the visited set. Yields the
+ /// id beside the group, because a resolved value has to be able to say which group it came from.
+ ///
+ /// Where to start: a host's group, or a group's parent.
+ /// Every group in the vaults being read, by item id.
+ public static IEnumerable<(Guid Id, HostGroupSecret Group)> Chain(
+ Guid? groupId,
+ IReadOnlyDictionary groups)
+ {
+ ArgumentNullException.ThrowIfNull(groups);
+
+ // Allocated per walk rather than shared, because these run concurrently on the connect path and on
+ // whatever thread a list is drawn from.
+ var visited = new HashSet();
+ var current = groupId;
+
+ while (current is { } id && visited.Add(id))
+ {
+ if (!groups.TryGetValue(id, out var group))
+ {
+ // A dangling id: the group was deleted on another machine. The host falls under the
+ // ungrouped heading and inherits nothing further, which is the same answer the interface
+ // gives for a group that is simply not there.
+ yield break;
+ }
+
+ yield return (id, group);
+
+ current = group.ParentId;
+ }
+ }
+
+ ///
+ /// Null means "this record did not answer", which is what lets the caller keep walking. It is not the
+ /// same as , which is an answer — and conflating the two
+ /// is how a host deliberately put back on a typed password would silently pick up its group's key.
+ ///
+ private static ResolvedBinding? BindingOf(HostSecret host) => host switch
+ {
+ { SshKeyId: { } key } => new ResolvedBinding(ResolvedBindingKind.SshKey, key, null),
+ { CredentialId: { } credential } =>
+ new ResolvedBinding(ResolvedBindingKind.Credential, credential, null),
+ { AsksForPassword: true } => ResolvedBinding.TypedByTheHost,
+ _ => null,
+ };
+
+ ///
+ /// A group has no equivalent of , so it either lends a binding
+ /// or says nothing. "Everything under here types its password" is not a thing a group can assert: it
+ /// would be indistinguishable from a group that simply has no default, and the two would resolve the
+ /// same way anyway once the chain ran out.
+ ///
+ private static ResolvedBinding? BindingOf(HostGroupSecret group, Guid groupId) => group switch
+ {
+ { DefaultSshKeyId: { } key } => new ResolvedBinding(ResolvedBindingKind.SshKey, key, groupId),
+ { DefaultCredentialId: { } credential } =>
+ new ResolvedBinding(ResolvedBindingKind.Credential, credential, groupId),
+ _ => null,
+ };
+}
diff --git a/src/DodoSSH.Client.Domain/HostSecret.cs b/src/DodoSSH.Client.Domain/HostSecret.cs
index 1d4c620..8b8c438 100644
--- a/src/DodoSSH.Client.Domain/HostSecret.cs
+++ b/src/DodoSSH.Client.Domain/HostSecret.cs
@@ -35,10 +35,37 @@ public sealed record HostSecret : IVaultSecret
/// Hostname or address to connect to.
public required string Hostname { get; init; }
- /// TCP port.
- public int Port { get; init; } = DefaultPort;
+ ///
+ /// TCP port, or null to take the group's.
+ ///
+ ///
+ ///
+ /// Nullable, and it had to become so: an defaulting to 22 has no way to say "I have no
+ /// port of my own". stays, as the last fallback after the group chain runs out
+ /// — so a host that inherits nothing still dials 22, exactly as it always did.
+ ///
+ ///
+ /// This is the field with the widest blast radius in the vault, and the sharp edge is on the way
+ /// out, not in. A host with a null port is written at a schema version older clients do not know,
+ /// and they will not merely find it read-only — they cannot decode it at all, because the property is
+ /// omitted, their int Port reads 0 and refuses it. That cost is
+ /// confined to hosts which actually inherit, because the codec stamps the lowest version that loses
+ /// nothing; the alternative, writing 22 into every host, would make inheritance a lie the moment a
+ /// group's default changed. See .
+ ///
+ ///
+ public int? Port { get; init; }
- /// Login user, when the host pins one.
+ ///
+ /// Login user: pinned when set, no username when empty, the group's when null.
+ ///
+ ///
+ /// Three states in one nullable string, and the empty one is not an oversight. Null used to mean "no
+ /// username", which the connect path refuses; it now means "whatever the group says", so the refusal
+ /// still has to be reachable or a host under a group could not opt out of the group's user. An empty
+ /// string is that opt-out: stored, resolved to nothing, and refused at connect with the same message it
+ /// always gave.
+ ///
public string? Username { get; init; }
/// Free-text notes.
@@ -58,10 +85,17 @@ public sealed record HostSecret : IVaultSecret
public HostOptions Options { get; init; } = HostOptions.Empty;
///
- /// The vault SSH key to authenticate with, or null to use a password.
+ /// The vault SSH key to authenticate with, or null to take the group's binding.
///
///
///
+ /// Null used to mean "use a typed password" and now means "ask the group". Nothing already stored
+ /// changed meaning when it did: no group could lend a binding until this build, so every host in every
+ /// vault that named neither a key nor a credential resolved then, and resolves now, to a password typed
+ /// at connect time. What is new is that a host under a group which does lend one can no longer
+ /// say "not that, ask me" by naming nothing — which is what is for.
+ ///
+ ///
/// An item id rather than the key itself, because the key is a vault item in its own right and a copy
/// embedded here would be a second copy of a private key to keep in step — rotated in one place and
/// stale in the other. The cost is that the reference can dangle: the key may be deleted on another
@@ -83,14 +117,15 @@ public sealed record HostSecret : IVaultSecret
public Guid? SshKeyId { get; init; }
///
- /// The vault credential to authenticate with, or null to be asked for a password.
+ /// The vault credential to authenticate with, or null to take the group's binding.
///
///
///
/// The password counterpart of , with the same reasoning about ids rather than
- /// copies, the same dangling-reference handling, and the same refusal to fall back when the reference
- /// cannot be resolved. One credential is very often the same account on twenty hosts, which is exactly
- /// why it is referenced and not embedded — a copy per host is twenty places to rotate and one to forget.
+ /// copies, the same dangling-reference handling, the same meaning for null, and the same refusal to fall
+ /// back when the reference cannot be resolved. One credential is very often the same account on twenty
+ /// hosts, which is exactly why it is referenced and not embedded — a copy per host is twenty places to
+ /// rotate and one to forget.
///
///
/// Mutually exclusive with . SSH itself would happily try a key and fall
@@ -98,9 +133,61 @@ public sealed record HostSecret : IVaultSecret
/// answer — and the interface, the connect path and the user would each be free to guess differently.
/// One host, one method; enforces it.
///
+ ///
+ /// Enforcing it per record is necessary and not sufficient. A host naming a credential under a
+ /// group naming a key is two individually valid records that resolve to two bindings, so the exclusion
+ /// is applied again across the chain — host over group — where the chain is walked. Nothing that
+ /// resolves a host may return both.
+ ///
///
public Guid? CredentialId { get; init; }
+ ///
+ /// Whether this host is pinned to a password typed at connect time, or null to leave it unstated.
+ ///
+ ///
+ ///
+ /// The fourth state, and the reason it needs a field of its own. A host can authenticate with a key,
+ /// with a stored credential, with a password typed each time, or with whatever its group lends it — and
+ /// two nullable ids can only express three of those. Naming neither id used to be the third; it is now
+ /// the fourth, so the third needs saying out loud.
+ ///
+ ///
+ /// Only means anything. Null is "not stated", which resolves through the
+ /// group chain and lands on a typed password if the chain lends nothing — so null and false would say
+ /// the same thing, and the codec writes null rather than false so that a host which never touched this
+ /// encodes exactly as it did before the field existed. A decoded is folded back
+ /// to null for the same reason: two spellings of one state is a difference the merge would report as a
+ /// change nobody made.
+ ///
+ ///
+ /// Refused beside a binding. A host that names a key and also says "ask me for a password" has
+ /// given two answers to one question, which is the same failure and
+ /// are kept apart to avoid.
+ ///
+ ///
+ public bool? AsksForPassword { get; init; }
+
+ ///
+ /// The tags this host wears, as tag item ids.
+ ///
+ ///
+ ///
+ /// Ids rather than names, because a tag is a vault item in its own right — see
+ /// for why renaming one has to be a single write. The set lives here rather than on the tag, and rather
+ /// than in the HostTag join the contract reserves, because tagging two hosts must be two writes
+ /// to two items; the one thing the join would add on top of that is bought instead by merging this per
+ /// id. See .
+ ///
+ ///
+ /// The references may dangle, exactly as may. A tag deleted on another
+ /// machine leaves an id here that resolves to nothing, and the host simply stops drawing that chip —
+ /// handled where it is noticed rather than prevented here, because preventing it would mean one tag
+ /// delete rewriting every host that wore it.
+ ///
+ ///
+ public TagSet TagIds { get; init; } = TagSet.Empty;
+
///
/// The group this host is filed under, or null for none.
///
@@ -168,12 +255,31 @@ public sealed record HostSecret : IVaultSecret
return false;
}
+ // Null is "take the group's port" and passes this comparison, which is the wanted behaviour and is
+ // silent about it: `is < 1 or > 65535` is false for a null int?. The message is interpolated only
+ // on the branch where the value exists, because a null renders as an empty string and would produce
+ // "not ." on screen.
if (Port is < 1 or > 65535)
{
- reason = $"Port must be between 1 and 65535, not {Port}.";
+ reason = $"Port must be between 1 and 65535, not {Port.Value}.";
return false;
}
+ return ReferencesAreStorable(out reason);
+ }
+
+ ///
+ /// Checks the five places a host points at something else, and the one answer it must not give twice.
+ ///
+ ///
+ /// Split out for length and they do belong together: every check here is about an id, and an id has
+ /// exactly two ways to be wrong. It can be , which is not "nothing" — nothing is
+ /// null — but a reference that can never resolve, so storing one produces a host that refuses to connect
+ /// with nothing on screen to say why. Or two ids can be present that answer the same question, which
+ /// leaves the interface, the connect path and the user each free to guess differently.
+ ///
+ private bool ReferencesAreStorable([NotNullWhen(false)] out string? reason)
+ {
if (JumpHostIds.AsSpan().Contains(Guid.Empty))
{
reason = "A jump chain cannot contain an empty host id.";
@@ -182,8 +288,6 @@ public sealed record HostSecret : IVaultSecret
if (SshKeyId == Guid.Empty)
{
- // An empty id is not "no key" — that is null. It is a reference that can never resolve, and
- // storing one would produce a host that refuses to connect with no way to see why.
reason = "An SSH key reference cannot be an empty id; use no key instead.";
return false;
}
@@ -200,12 +304,24 @@ public sealed record HostSecret : IVaultSecret
return false;
}
+ if (AsksForPassword is true && (SshKeyId is not null || CredentialId is not null))
+ {
+ reason = "A host asks for a password or binds a key or credential, not both.";
+ return false;
+ }
+
if (GroupId == Guid.Empty)
{
reason = "A group reference cannot be an empty id; use no group instead.";
return false;
}
+ if (TagIds.Contains(Guid.Empty))
+ {
+ reason = "A tag reference cannot be an empty id.";
+ return false;
+ }
+
reason = null;
return true;
}
diff --git a/src/DodoSSH.Client.Domain/HostSecretCodec.cs b/src/DodoSSH.Client.Domain/HostSecretCodec.cs
index d39b730..a636b3e 100644
--- a/src/DodoSSH.Client.Domain/HostSecretCodec.cs
+++ b/src/DodoSSH.Client.Domain/HostSecretCodec.cs
@@ -65,8 +65,49 @@ public static class HostSecretCodec
/// The version that introduced .
public const int GroupIdSchemaVersion = 4;
+ ///
+ /// The version that introduced inheritance: a null and
+ /// .
+ ///
+ ///
+ ///
+ /// The one version where "read-only on an older client" understates the cost. Every field before
+ /// this one is additive: an older build decodes the host, shows it, and refuses to save it. A null port
+ /// is subtractive — the property is omitted, an older build's int Port reads 0, and
+ /// refuses the host outright. The item does not appear locked on
+ /// that machine; it does not appear at all.
+ ///
+ ///
+ /// Accepted rather than worked around, because the two workarounds are worse. Writing 22 into every host
+ /// makes inheritance a lie the moment a group's default changes, and it is what inheritance exists to
+ /// stop. Keeping a second non-inheriting port field beside this one would mean two ports per host and a
+ /// rule about which wins, on every screen and on the wire. What confines the cost is
+ /// : only a host that actually inherits its port is written here.
+ ///
+ ///
+ /// shares this version because it arrives in the same build and
+ /// answers the same question — it is what a host says instead of naming a binding, once naming nothing
+ /// has come to mean "ask the group".
+ ///
+ ///
+ public const int PortInheritSchemaVersion = 5;
+
+ /// The version that introduced .
+ ///
+ /// One past inheritance rather than sharing with it, because the two are independent: a host can wear
+ /// tags without inheriting anything, and such a host stays decodable on a build that knows 5. That
+ /// distinction is unobservable today — both shipped together — and it is stated anyway, because the rule
+ /// this file follows is one version per field and the exception would have to be re-justified by whoever
+ /// adds the seventh.
+ ///
+ public const int TagIdsSchemaVersion = 6;
+
/// The highest schema version this build can write.
- public const int CurrentSchemaVersion = GroupIdSchemaVersion;
+ ///
+ /// Names the highest constant above, which assumes when it takes a
+ /// maximum. A new field added below this line has to be named here too.
+ ///
+ public const int CurrentSchemaVersion = TagIdsSchemaVersion;
/// Serialises a host to the bytes that get sealed.
/// The host is not valid for storage.
@@ -99,6 +140,15 @@ public static class HostSecretCodec
SshKeyId = host.SshKeyId,
CredentialId = host.CredentialId,
GroupId = host.GroupId,
+
+ // Null rather than false, so a host that never said anything about this encodes exactly as it
+ // did before the field existed. See HostSecret.AsksForPassword.
+ AsksForPassword = host.AsksForPassword is true ? true : null,
+
+ // Null rather than an empty array, for the same reason and with a wider blast radius: an empty
+ // [] here would land in every host in every vault and make the first sync after the upgrade
+ // read as though every one of them had changed.
+ TagIds = host.TagIds.Count == 0 ? null : [.. host.TagIds],
};
return JsonSerializer.SerializeToUtf8Bytes(
@@ -156,6 +206,19 @@ public static class HostSecretCodec
version = Math.Max(version, GroupIdSchemaVersion);
}
+ // Both halves of inheritance, and this is the branch that loses an item rather than locking one if
+ // it is forgotten — see PortInheritSchemaVersion. A host stamped at 4 with its port omitted is a
+ // host an older client deletes from its own view.
+ if (host.Port is null || host.AsksForPassword is true)
+ {
+ version = Math.Max(version, PortInheritSchemaVersion);
+ }
+
+ if (host.TagIds.Count > 0)
+ {
+ version = Math.Max(version, TagIdsSchemaVersion);
+ }
+
return version;
}
@@ -226,6 +289,12 @@ public static class HostSecretCodec
SshKeyId = parsed.SshKeyId,
CredentialId = parsed.CredentialId,
GroupId = parsed.GroupId,
+
+ // False folds back to null: they say the same thing, and letting both onto the record would
+ // give the merge two spellings of one state to report as a change nobody made.
+ AsksForPassword = parsed.AsksForPassword is true ? true : null,
+
+ TagIds = TagSet.Create(parsed.TagIds ?? []),
};
if (!candidate.TryValidate(out _))
@@ -255,7 +324,13 @@ internal sealed class HostPayloadDocument
public string? Hostname { get; set; }
- public int Port { get; set; }
+ ///
+ /// Nullable, which is what removes the key from the JSON for a host that inherits its port. It is also
+ /// the one property here whose absence an older build cannot survive: it deserialises as
+ /// 0 there, which refuses. See
+ /// .
+ ///
+ public int? Port { get; set; }
public string? Username { get; set; }
@@ -284,6 +359,16 @@ internal sealed class HostPayloadDocument
///
public Guid? GroupId { get; set; }
+
+ ///
+ public bool? AsksForPassword { get; set; }
+
+ ///
+ /// Last, and it must stay last for the reason gives. Null when the host wears no
+ /// tags, never [] — an empty array would be a new key in the JSON of every host in every vault,
+ /// which the sync engine would read as every host having changed.
+ ///
+ public Guid[]? TagIds { get; set; }
}
[JsonSourceGenerationOptions(
diff --git a/src/DodoSSH.Client.Domain/HostSecretMerge.cs b/src/DodoSSH.Client.Domain/HostSecretMerge.cs
index 48f6780..871d402 100644
--- a/src/DodoSSH.Client.Domain/HostSecretMerge.cs
+++ b/src/DodoSSH.Client.Domain/HostSecretMerge.cs
@@ -84,7 +84,7 @@ public static class HostSecretMerge
local.Port,
remote.Port,
conflicts,
- static port => port.ToString(CultureInfo.InvariantCulture)),
+ static port => port?.ToString(CultureInfo.InvariantCulture) ?? "the group's port"),
Username = Text(
nameof(HostSecret.Username), ancestor.Username, local.Username, remote.Username, conflicts),
Notes = Text(nameof(HostSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
@@ -96,6 +96,7 @@ public static class HostSecretMerge
conflicts,
FormatChain),
Options = MergeOptions(ancestor.Options, local.Options, remote.Options, conflicts),
+ TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts),
RelayEnabled = Field(
nameof(HostSecret.RelayEnabled),
ancestor.RelayEnabled,
@@ -155,6 +156,14 @@ public static class HostSecretMerge
remote.GroupId,
conflicts,
static id => id?.ToString() ?? "ungrouped"),
+
+ AsksForPassword = Field(
+ nameof(HostSecret.AsksForPassword),
+ ancestor.AsksForPassword,
+ local.AsksForPassword,
+ remote.AsksForPassword,
+ conflicts,
+ static asked => asked is true ? "a typed password" : "the group's binding"),
};
private static string Text(
@@ -231,6 +240,56 @@ public static class HostSecretMerge
merge.Merged.Select(entry => new HostOption(entry.Key, entry.Value)));
}
+ ///
+ /// Merges the tag set per tag, so two people each adding a different one both keep theirs.
+ ///
+ ///
+ ///
+ /// The reason exposes a map at all. A whole-value merge here would take one side's
+ /// set entire and drop the other's — so a colleague tagging a host "pci" while you tagged it "eu-west"
+ /// would silently lose one of the two, which is the single most visible difference between a field-level
+ /// merge and last-writer-wins.
+ ///
+ ///
+ /// No conflict is reachable, and the loop below is kept anyway. The value in the map is the key,
+ /// so a tag can only be present or absent — and running that through
+ /// leaves nothing to disagree about. Where both sides hold the tag or
+ /// neither does they agree; where exactly one side moved, the other still matches the ancestor and the
+ /// move is taken. The "both sides moved differently" branch needs one key to hold two values, which this
+ /// map cannot express. A set is the one collection shape that merges without ever asking the user.
+ ///
+ ///
+ /// The loop stays because that proof depends on keying by the value, which
+ /// is one edit away from ceasing to be true — and the failure it would cause is a discarded tag that
+ /// nothing records. Unreachable code that costs a foreach over an empty list is a cheaper way to
+ /// hold that invariant than a comment alone.
+ ///
+ ///
+ private static TagSet MergeTags(
+ TagSet ancestor,
+ TagSet local,
+ TagSet remote,
+ List conflicts)
+ {
+ var merge = ThreeWayMerge.Map(
+ ancestor.ToIdMap(),
+ local.ToIdMap(),
+ remote.ToIdMap(),
+ EqualityComparer.Default);
+
+ foreach (var conflict in merge.Conflicts)
+ {
+ conflicts.Add(new HostFieldConflict(
+ $"{nameof(HostSecret.TagIds)}[{conflict.Key}]",
+ conflict.DiscardedSide,
+ conflict.Kept == Guid.Empty ? "not tagged" : "tagged",
+ conflict.DiscardedWasRemoval ? "not tagged" : "tagged",
+ conflict.DiscardedWasRemoval));
+ }
+
+ return TagSet.Create(merge.Merged.Keys);
+ }
+
private static string FormatChain(JumpChain chain) =>
chain.Count == 0 ? "(none)" : string.Join(" → ", chain);
}
diff --git a/src/DodoSSH.Client.Domain/TagSecret.cs b/src/DodoSSH.Client.Domain/TagSecret.cs
new file mode 100644
index 0000000..8a0561d
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/TagSecret.cs
@@ -0,0 +1,61 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace DodoSSH.Client.Domain;
+
+///
+/// A label that can be put on many hosts, decrypted.
+///
+///
+///
+/// One field, and the one field is the whole argument for the type existing. A tag is only worth having
+/// because the same tag goes on twenty machines and filters them back out later, so it needs an identity of
+/// its own: renaming "staging" to "stage" is then one write to one item, rather than twenty host payloads
+/// that have to be found, decrypted, edited and pushed — nineteen of which can fail halfway and leave a
+/// vault holding two spellings of one tag.
+///
+///
+/// Membership is a set on the host, not a member list here and not the
+/// HostTag join the contract reserves. The pointer-on-the-host argument that decided
+/// applies unchanged — tagging two hosts is two writes to two items — and
+/// the extra thing a join would have bought, two machines tagging the same host without one losing,
+/// is already bought by , which resolves a keyed collection key by key and so
+/// gives set semantics with removals. SyncEntityType.HostTag therefore stays reserved and unused.
+///
+///
+/// No colour, no description, no ordering. Each was considered and each would be a field two clients
+/// can disagree about in exchange for nothing the filter needs. If one arrives later it arrives behind a
+/// schema version, which is what the envelope in is for.
+///
+///
+public sealed record TagSecret : IVaultSecret
+{
+ /// What the tag is called. The only name it has anywhere.
+ public required string Label { get; init; }
+
+ /// Whether this is storable, and why not if it is not.
+ ///
+ ///
+ /// A blank name is refused rather than defaulted, for a sharper reason than a group's: a tag is drawn as
+ /// a chip beside a host and selected from a list of chips, so a nameless one is an empty chip that
+ /// filters a set nobody can name.
+ ///
+ ///
+ /// A duplicate name is not refused here, and cannot be — this record is one tag and knows nothing
+ /// about the others. Two tags called "staging" are two tags, which is a mess the editor should warn about
+ /// and the vault must still be able to hold: two people creating the same tag offline is exactly how it
+ /// happens, and refusing to store the second one would mean dropping the tags a colleague had already
+ /// put on their hosts.
+ ///
+ ///
+ public bool TryValidate([NotNullWhen(false)] out string? reason)
+ {
+ if (string.IsNullOrWhiteSpace(Label))
+ {
+ reason = "A tag needs a name.";
+ return false;
+ }
+
+ reason = null;
+ return true;
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/TagSecretCodec.cs b/src/DodoSSH.Client.Domain/TagSecretCodec.cs
new file mode 100644
index 0000000..364658a
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/TagSecretCodec.cs
@@ -0,0 +1,119 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace DodoSSH.Client.Domain;
+
+/// A decoded tag payload, together with the schema version it was written at.
+/// The tag.
+/// The version the writing client used.
+public sealed record TagSecretDocument(TagSecret Tag, int SchemaVersion)
+{
+ ///
+ public bool IsReadOnly => SchemaVersion > TagSecretCodec.CurrentSchemaVersion;
+}
+
+///
+/// Encodes and decodes the plaintext inside a tag item's encrypted payload.
+///
+///
+/// Mirrors , for the same reasons and with the same guarantees. One field
+/// makes this look like ceremony around a string, and it is not: what the JSON envelope buys is a schema
+/// version, which is what lets a later build add a field without every older client silently dropping it on
+/// the next edit. is the type that had to spend that budget, and the shape
+/// here is the shape it had beforehand. See .
+///
+public static class TagSecretCodec
+{
+ /// The first version, and the one a tag with no newer field is still written at.
+ public const int BaseSchemaVersion = 1;
+
+ /// The highest schema version this build can write.
+ public const int CurrentSchemaVersion = BaseSchemaVersion;
+
+ /// Serialises a tag to the bytes that get sealed.
+ /// The tag is not valid for storage.
+ public static byte[] Encode(TagSecret tag)
+ {
+ ArgumentNullException.ThrowIfNull(tag);
+
+ if (!tag.TryValidate(out var reason))
+ {
+ throw new ArgumentException(reason, nameof(tag));
+ }
+
+ var document = new TagPayloadDocument
+ {
+ SchemaVersion = SchemaVersionFor(tag),
+ Label = tag.Label,
+ };
+
+ return JsonSerializer.SerializeToUtf8Bytes(
+ document, TagPayloadJsonContext.Default.TagPayloadDocument);
+ }
+
+ ///
+ /// The lowest schema version that can represent this tag without losing anything.
+ ///
+ ///
+ /// With one field there is nothing to take a maximum over, and this returns a constant. It is a method
+ /// anyway, because the alternative — stamping at the call site — is
+ /// indistinguishable from this today and becomes wrong the moment a second field exists: the version is
+ /// what makes an older client treat an item as read-only, so a build that stamps the newest one
+ /// unconditionally makes every tag in the vault uneditable everywhere as soon as one machine upgrades
+ /// and renames one. shipped without this and had to grow it; the
+ /// method is here so the second field has an obvious place to go rather than a call site to notice.
+ ///
+ private static int SchemaVersionFor(TagSecret tag) => BaseSchemaVersion;
+
+ /// Parses a decrypted payload.
+ ///
+ public static bool TryDecode(
+ ReadOnlySpan payload,
+ [NotNullWhen(true)] out TagSecretDocument? document)
+ {
+ document = null;
+
+ TagPayloadDocument? parsed;
+ try
+ {
+ parsed = JsonSerializer.Deserialize(
+ payload, TagPayloadJsonContext.Default.TagPayloadDocument);
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+
+ if (parsed is null || parsed.SchemaVersion < 1)
+ {
+ return false;
+ }
+
+ var candidate = new TagSecret { Label = parsed.Label ?? string.Empty };
+
+ if (!candidate.TryValidate(out _))
+ {
+ return false;
+ }
+
+ document = new TagSecretDocument(candidate, parsed.SchemaVersion);
+ return true;
+ }
+}
+
+/// The serialised shape. Mutable and nullable because it models untrusted input.
+///
+internal sealed class TagPayloadDocument
+{
+ public int SchemaVersion { get; set; }
+
+ public string? Label { get; set; }
+}
+
+[JsonSourceGenerationOptions(
+ PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
+[JsonSerializable(typeof(TagPayloadDocument))]
+internal sealed partial class TagPayloadJsonContext : JsonSerializerContext;
diff --git a/src/DodoSSH.Client.Domain/TagSecretMerge.cs b/src/DodoSSH.Client.Domain/TagSecretMerge.cs
new file mode 100644
index 0000000..8b6ac5e
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/TagSecretMerge.cs
@@ -0,0 +1,68 @@
+namespace DodoSSH.Client.Domain;
+
+/// The merged tag, and everything that had to be overridden to produce it.
+/// The tag to store and push.
+/// Empty when the two sides were reconcilable field by field.
+public sealed record TagMergeResult(
+ TagSecret Merged,
+ IReadOnlyList Conflicts)
+{
+ /// Whether anything had to be overridden.
+ public bool HasConflicts => Conflicts.Count > 0;
+}
+
+///
+/// Merges two divergent versions of a tag against the version they both started from.
+///
+///
+///
+/// One scalar, so this is now the simplest merge in the client, and the interesting thing about it is what
+/// it never has to consider. Putting a tag on a host does not write to the tag — membership is a set on the
+/// host — so two people tagging two different machines at the same time cannot reach this code. The only way
+/// to get here is for two people to rename the same tag differently, which is a real disagreement and gets a
+/// conflict notice.
+///
+///
+/// Two tags that end up with the same name are not merged into one, here or anywhere. They are two
+/// items with two ids and two sets of hosts, and collapsing them would mean rewriting every host that named
+/// the loser — the exact N-payload write that keeping membership on the host exists to avoid. A duplicate is
+/// something the editor should warn about before it is created, not something the merge silently repairs
+/// afterwards.
+///
+///
+/// Nothing is redacted. A tag name is the one thing a tag has, and a notice saying only that "the name
+/// differed" would leave the user unable to tell which of their two names survived.
+///
+///
+public static class TagSecretMerge
+{
+ /// Produces the merged tag.
+ /// The version both sides branched from.
+ /// The pending local version.
+ /// The server's current version.
+ public static TagMergeResult Merge(TagSecret ancestor, TagSecret local, TagSecret remote)
+ {
+ ArgumentNullException.ThrowIfNull(ancestor);
+ ArgumentNullException.ThrowIfNull(local);
+ ArgumentNullException.ThrowIfNull(remote);
+
+ var conflicts = new List();
+
+ var merge = ThreeWayMerge.Scalar(
+ ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal);
+
+ if (merge.IsConflicted)
+ {
+ // The local side always loses a scalar clash — see ThreeWayMerge — so the discarded side is
+ // fixed here rather than derived from the outcome.
+ conflicts.Add(new HostFieldConflict(
+ nameof(TagSecret.Label),
+ MergeSide.Local,
+ merge.Value,
+ merge.Discarded,
+ DiscardedWasRemoval: false));
+ }
+
+ return new TagMergeResult(new TagSecret { Label = merge.Value }, conflicts);
+ }
+}
diff --git a/src/DodoSSH.Client.Domain/TagSet.cs b/src/DodoSSH.Client.Domain/TagSet.cs
new file mode 100644
index 0000000..8b26f0c
--- /dev/null
+++ b/src/DodoSSH.Client.Domain/TagSet.cs
@@ -0,0 +1,164 @@
+using System.Collections;
+using System.Diagnostics.CodeAnalysis;
+
+namespace DodoSSH.Client.Domain;
+
+///
+/// The tags a host wears, as item ids, in no meaningful order.
+///
+///
+///
+/// A dedicated type rather than a list of ids, for the reason gives: a plain
+/// on a record gets reference equality from the compiler-generated
+/// Equals, so every host would read as changed on every sync pass and two identical edits would
+/// register as a conflict.
+///
+///
+/// Unlike a jump chain, this is a set, and the difference is the whole point. A route's order is its
+/// meaning; a tag list's order is an artefact of which chip the user tapped first. So this sorts and
+/// deduplicates at construction, which makes two users who added the same two tags in opposite orders
+/// produce the same value — and therefore no change to push and nothing to merge. It also means the merge
+/// may treat it as a set: see .
+///
+///
+/// Empty ids are not rejected at construction. They arrive from a decrypted payload written by another
+/// client, and a constructor that threw would turn one bad item into a failed sync pass for every other item
+/// behind it. is where that is caught.
+///
+///
+public sealed class TagSet : IReadOnlyList, IEquatable
+{
+ private readonly Guid[] ids;
+ private readonly int hash;
+
+ private TagSet(Guid[] ids)
+ {
+ this.ids = ids;
+ hash = ComputeHash(ids);
+ }
+
+ /// No tags.
+ public static TagSet Empty { get; } = new([]);
+
+ ///
+ public int Count => ids.Length;
+
+ ///
+ public Guid this[int index] => ids[index];
+
+ /// Copies a sequence of tag ids, sorting and removing repeats.
+ public static TagSet Create(IEnumerable ids)
+ {
+ ArgumentNullException.ThrowIfNull(ids);
+
+ return Canonicalise([.. ids]);
+ }
+
+ /// Copies a span of tag ids, sorting and removing repeats.
+ public static TagSet Create(ReadOnlySpan ids) => Canonicalise(ids.ToArray());
+
+ /// Whether this host wears the given tag.
+ public bool Contains(Guid id) => Array.BinarySearch(ids, id) >= 0;
+
+ ///
+ /// The set as a map from tag id to tag id, which is the shape takes.
+ ///
+ ///
+ ///
+ /// The value repeats the key deliberately. A per-key merge resolves presence and absence independently,
+ /// which is set semantics with removals — so two people each adding a different tag to one host both
+ /// keep theirs, and a removal on one side is reported rather than silently undone. A whole-value merge
+ /// would drop one side outright.
+ ///
+ ///
+ /// This is the reason there is no HostTag join item. The one thing a join buys over a set
+ /// on the host — two machines tagging the same host without either losing — is exactly what a keyed
+ /// merge already gives. See .
+ ///
+ ///
+ /// Because the value is the key, no key can ever have two different values, so a conflict here can only
+ /// ever be one side adding what the other removed. That is the only disagreement a set can have.
+ ///
+ ///
+ public IReadOnlyDictionary ToIdMap() => ids.ToDictionary(id => id);
+
+ ///
+ public bool Equals(TagSet? other)
+ {
+ if (ReferenceEquals(this, other))
+ {
+ return true;
+ }
+
+ return other is not null
+ && other.hash == hash
+ && ids.AsSpan().SequenceEqual(other.ids);
+ }
+
+ ///
+ public override bool Equals(object? obj) => Equals(obj as TagSet);
+
+ ///
+ public override int GetHashCode() => hash;
+
+ ///
+ public IEnumerator GetEnumerator() => ((IEnumerable)ids).GetEnumerator();
+
+ ///
+ IEnumerator IEnumerable.GetEnumerator() => ids.GetEnumerator();
+
+ /// The ids, without copying, in sorted order.
+ public ReadOnlySpan AsSpan() => ids;
+
+ /// Contents equality, tolerating nulls on either side.
+ [SuppressMessage(
+ "Usage",
+ "CA2225:Operator overloads have named alternates",
+ Justification = "Equals(TagSet) is the named alternate.")]
+ public static bool operator ==(TagSet? left, TagSet? right) =>
+ left is null ? right is null : left.Equals(right);
+
+ /// Contents inequality.
+ public static bool operator !=(TagSet? left, TagSet? right) => !(left == right);
+
+ ///
+ /// Sorting is what makes the sequence comparison below a set comparison, and deduplicating is what stops
+ /// a payload that repeats an id from comparing unequal to the same set written once. Both happen here,
+ /// once, rather than at every comparison.
+ ///
+ private static TagSet Canonicalise(Guid[] ids)
+ {
+ if (ids.Length == 0)
+ {
+ return Empty;
+ }
+
+ Array.Sort(ids);
+
+ var written = 1;
+ for (var read = 1; read < ids.Length; read++)
+ {
+ if (ids[read] != ids[written - 1])
+ {
+ ids[written++] = ids[read];
+ }
+ }
+
+ return new TagSet(written == ids.Length ? ids : ids[..written]);
+ }
+
+ private static int ComputeHash(Guid[] ids)
+ {
+ // Order-sensitive over an already-sorted array, which is order-insensitive over the set. Written
+ // this way rather than by summing or xoring hashes because those collide on swapped pairs.
+ var accumulator = new HashCode();
+ accumulator.Add(ids.Length);
+
+ foreach (var id in ids)
+ {
+ accumulator.Add(id);
+ }
+
+ return accumulator.ToHashCode();
+ }
+}
diff --git a/src/DodoSSH.Client.Session/VaultSession.cs b/src/DodoSSH.Client.Session/VaultSession.cs
index a36a43e..c9f8f31 100644
--- a/src/DodoSSH.Client.Session/VaultSession.cs
+++ b/src/DodoSSH.Client.Session/VaultSession.cs
@@ -122,6 +122,7 @@ public sealed partial class VaultSession : IAsyncDisposable
Credentials = new CredentialRepository(Items, Outbox, keyring, activity);
KnownHosts = new KnownHostRepository(Items, Outbox, keyring, activity);
HostGroups = new HostGroupRepository(Items, Outbox, keyring, activity);
+ Tags = new TagRepository(Items, Outbox, keyring, activity);
Snippets = new SnippetRepository(Items, Outbox, keyring, activity);
ObjectStores = new ObjectStoreRepository(Items, Outbox, keyring, activity);
}
@@ -175,12 +176,20 @@ public sealed partial class VaultSession : IAsyncDisposable
/// The groups hosts are filed under, decrypted, with unpushed local changes laid over them.
///
- /// Membership is not in here. Each host carries its own GroupId, so a group is only ever a name —
- /// which is what makes filing two hosts at once on two machines two independent writes rather than one
- /// contested one.
+ /// Membership is not in here. Each host carries its own GroupId, so a group is only ever a name,
+ /// a parent and the defaults hosts under it fall back to — which is what makes filing two hosts at once
+ /// on two machines two independent writes rather than one contested one.
///
public HostGroupRepository HostGroups { get; }
+ /// The tags hosts wear, decrypted, with unpushed local changes laid over them.
+ ///
+ /// Membership is not in here either, and for the same reason once removed: each host carries its own set
+ /// of tag ids. Read beside whenever a host list is drawn, because a tag id on a
+ /// host resolves to a name only through this.
+ ///
+ public TagRepository Tags { get; }
+
/// Saved commands, decrypted, with unpushed local changes laid over them.
public SnippetRepository Snippets { get; }
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index 5ac98eb..ae65195 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -804,6 +804,17 @@ internal sealed partial class VaultViewModel(
///
private IReadOnlyList> groupItems = [];
+ ///
+ /// The same groups by id, which is the shape the inheritance walk takes.
+ ///
+ ///
+ /// Cached beside rather than built per call, because resolving is on the path
+ /// that draws every host row and on the path that opens every shell — and a dictionary rebuilt per host
+ /// would be one allocation per row per redraw.
+ ///
+ private IReadOnlyDictionary groupsById =
+ new Dictionary();
+
/// The groups whose hosts are folded away, by id, with for ungrouped.
private readonly HashSet collapsedGroups = [];
@@ -1813,10 +1824,30 @@ internal sealed partial class VaultViewModel(
.ConfigureAwait(true);
groupItems = [.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)];
+ groupsById = groupItems.ToDictionary(group => group.EntityId, group => group.Secret);
return listing.Unreadable;
}
+ ///
+ /// A host with its group chain applied: the port to dial, the user to log in as, and how to
+ /// authenticate.
+ ///
+ ///
+ ///
+ /// The one place this view model turns a stored host into a dialled one. A host may leave any of the
+ /// three unset and take its group's, so reading host.Port or host.SshKeyId directly
+ /// answers "what did the user type into this host" and not "what happens when this is connected" — and
+ /// almost everything on screen wants the second question. See .
+ ///
+ ///
+ /// Reads , which is refilled by before the hosts
+ /// are read. That ordering is not incidental: a host resolved against a stale group list would show one
+ /// port and dial another.
+ ///
+ ///
+ internal ResolvedHost Resolve(HostSecret host) => HostInheritance.Resolve(host, groupsById);
+
/// Refills , counting the hosts filed under each.
private void RebuildGroups()
{
@@ -2177,7 +2208,7 @@ internal sealed partial class VaultViewModel(
// Built once rather than searched per pin. A vault with a hundred of each would otherwise be a
// hundred scans of the host list on every background sync.
var dialled = Hosts
- .Select(host => Endpoint(host.Host.Hostname, host.Host.Port))
+ .Select(host => Endpoint(host.Host.Hostname, Resolve(host.Host).Port.Value))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
// Listed across every readable vault, unlike the trust the SSH handshake consults, which stays in
@@ -2521,7 +2552,10 @@ internal sealed partial class VaultViewModel(
editingHostVaultId = row.VaultId;
EditorLabel = row.Host.Label;
EditorHostname = row.Host.Hostname;
- EditorPort = row.Host.Port;
+ // The resolved port rather than the stored one, so a host that inherits opens showing what it
+ // actually dials rather than an empty box. The editor cannot yet express "leave this to the group",
+ // so saving pins whatever is shown — which is what it did before any of this existed.
+ EditorPort = Resolve(row.Host).Port.Value;
EditorUsername = row.Host.Username ?? string.Empty;
EditorNotes = row.Host.Notes ?? string.Empty;
EditorRelayEnabled = row.Host.RelayEnabled;
@@ -3807,7 +3841,11 @@ internal sealed partial class VaultViewModel(
}
var address = row.Host.Hostname;
- var port = row.Host.Port;
+
+ // The dialled port, because that is what the pin is filed under. A host inheriting 2222 from its
+ // group was pinned at 2222, and forgetting under 22 would leave the pin that caused the mismatch
+ // exactly where it was.
+ var port = Resolve(row.Host).Port.Value;
await RunAsync(
$"Forgetting the pinned host key for {address}…",
@@ -4004,7 +4042,7 @@ internal sealed partial class VaultViewModel(
var request = new SshConnectionRequest(
row.Host.Hostname,
- row.Host.Port,
+ Resolve(row.Host).Port.Value,
authentication.Username,
authentication.Credential);
@@ -4170,7 +4208,10 @@ internal sealed partial class VaultViewModel(
}
request = new SshConnectionRequest(
- host.Hostname, host.Port, authentication.Username, authentication.Credential);
+ host.Hostname,
+ Resolve(host).Port.Value,
+ authentication.Username,
+ authentication.Credential);
return true;
}
diff --git a/src/DodoSSH.Client.Sync/ItemKinds.cs b/src/DodoSSH.Client.Sync/ItemKinds.cs
index 797b240..32cc28f 100644
--- a/src/DodoSSH.Client.Sync/ItemKinds.cs
+++ b/src/DodoSSH.Client.Sync/ItemKinds.cs
@@ -154,6 +154,9 @@ internal static class ItemKinds
(SyncEntityType.HostGroup, static (outbox, conflicts, keyring) =>
new ItemReconciler(HostGroupKind.Instance, outbox, conflicts, keyring)),
+ (SyncEntityType.Tag, static (outbox, conflicts, keyring) =>
+ new ItemReconciler(TagKind.Instance, outbox, conflicts, keyring)),
+
(SyncEntityType.Snippet, static (outbox, conflicts, keyring) =>
new ItemReconciler(SnippetKind.Instance, outbox, conflicts, keyring)),
@@ -267,7 +270,9 @@ internal sealed class HostKind : IItemKind
Note(changed, "Relay", before.RelayEnabled, after.RelayEnabled);
Note(changed, "SSH key", before.SshKeyId, after.SshKeyId);
Note(changed, "Credential", before.CredentialId, after.CredentialId);
+ Note(changed, "Password prompt", before.AsksForPassword, after.AsksForPassword);
Note(changed, "Group", before.GroupId, after.GroupId);
+ Note(changed, "Tags", before.TagIds, after.TagIds);
return changed;
}
@@ -588,13 +593,14 @@ internal sealed class HostGroupKind : IItemKind
HostGroupCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
///
- /// Nothing, and the field it declines to send is the one named after this type.
+ /// Nothing, and the two fields it declines to send are the two this type could have filled.
///
///
- /// SyncPlaintextFields.GroupId exists, the server had a column for it, and no client ever wrote
- /// one. What it would have handed over is a clustering of the estate — which machines this user files
- /// together — for a column nothing in the product reads. The server now refuses the field outright, on
- /// hosts as well as here. See ADR 0004.
+ /// SyncPlaintextFields has a GroupId and a ParentId, the server had a column for the
+ /// first, and no client ever wrote either. What they would hand over is the shape of the estate — which
+ /// machines this user files together, and which of those groupings sit under which — for columns nothing
+ /// in the product reads. Groups nest now and the parent is still not sent: it lives in the payload, and
+ /// the server refuses the plaintext field outright, on hosts as well as here. See ADR 0004.
///
///
public SyncPlaintextFields? Fields(HostGroupSecret secret) => null;
@@ -619,6 +625,11 @@ internal sealed class HostGroupKind : IItemKind
var changed = new List();
Note(changed, "Name", before.Label, after.Label);
+ Note(changed, "Parent", before.ParentId, after.ParentId);
+ Note(changed, "Default port", before.DefaultPort, after.DefaultPort);
+ Note(changed, "Default username", before.DefaultUsername, after.DefaultUsername);
+ Note(changed, "Default SSH key", before.DefaultSshKeyId, after.DefaultSshKeyId);
+ Note(changed, "Default credential", before.DefaultCredentialId, after.DefaultCredentialId);
return changed;
}
@@ -632,6 +643,88 @@ internal sealed class HostGroupKind : IItemKind
}
}
+/// Tags.
+internal sealed class TagKind : IItemKind
+{
+ internal static TagKind Instance { get; } = new();
+
+ ///
+ public SyncEntityType EntityType => SyncEntityType.Tag;
+
+ ///
+ public string Noun => "tag";
+
+ ///
+ public OpenedItem? TryOpen(
+ EncryptedPayload payload,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ int itemVersion)
+ {
+ var document = TagCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
+
+ return document is null ? null : new OpenedItem(document.Tag, document.IsReadOnly);
+ }
+
+ ///
+ public EncryptedPayload Seal(
+ TagSecret secret,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ uint keyGeneration,
+ int itemVersion) =>
+ TagCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
+
+ ///
+ /// Nothing, and null rather than an empty .
+ ///
+ ///
+ ///
+ /// The distinction is not pedantry. An empty record still serialises relayEnabled: false, which
+ /// invites every reader of the wire — including a future implementation of this client — to conclude
+ /// that a tag has a relay setting and that it is switched off. Null says the type has no plaintext at
+ /// all, which is the true statement.
+ ///
+ ///
+ /// A tag name in the clear would be the most aggregable column in the schema. Groups file machines;
+ /// tags describe them — "pci", "customer-a", "eu-west" — and one tag spans twenty hosts by design, so a
+ /// column here would hand the operator a labelled map of every user's estate for a sort no server in
+ /// this product performs. See ADR 0004.
+ ///
+ ///
+ ///
+ public SyncPlaintextFields? Fields(TagSecret secret) => null;
+
+ ///
+ public MergedItem Merge(TagSecret ancestor, TagSecret local, TagSecret remote)
+ {
+ var merged = TagSecretMerge.Merge(ancestor, local, remote);
+
+ return new MergedItem(merged.Merged, merged.Conflicts);
+ }
+
+ ///
+ public IReadOnlyList Changes(TagSecret before, TagSecret after)
+ {
+ ArgumentNullException.ThrowIfNull(before);
+ ArgumentNullException.ThrowIfNull(after);
+
+ var changed = new List();
+
+ Note(changed, "Name", before.Label, after.Label);
+
+ return changed;
+ }
+
+ ///
+ public TagSecret Relabel(TagSecret secret, string label)
+ {
+ ArgumentNullException.ThrowIfNull(secret);
+
+ return secret with { Label = label };
+ }
+}
+
/// Snippets.
internal sealed class SnippetKind : IItemKind
{
diff --git a/src/DodoSSH.Client.Sync/TagCipher.cs b/src/DodoSSH.Client.Sync/TagCipher.cs
new file mode 100644
index 0000000..301482f
--- /dev/null
+++ b/src/DodoSSH.Client.Sync/TagCipher.cs
@@ -0,0 +1,132 @@
+using System.Security.Cryptography;
+using DodoSSH.Client.Domain;
+using DodoSSH.Contracts;
+using DodoSSH.Crypto;
+
+namespace DodoSSH.Client.Sync;
+
+///
+/// Turns a tag into an item payload and back.
+///
+///
+///
+/// Mirrors exactly, including the rule that a payload is sealed at the version
+/// the server will assign rather than the one it replaces — see .
+///
+///
+/// The resource type is the one thing not to copy, and this type is where copying it is most tempting.
+/// SyncEntityType.Tag is 5 and CryptoSpec.AadResourceType.Tag is 8, because the crypto enum
+/// carries None, User, Device and Vault ahead of the item types. A cast from one to the other compiles, and
+/// 5 in the crypto enum is Credential — so every tag in the vault would be sealed under the resource
+/// type for a password. It would encrypt perfectly and decrypt perfectly on the machine that wrote it, and
+/// the first thing to notice would be another implementation refusing the item. The AAD is frozen into
+/// stored ciphertext, so by then it is not a bug that can be fixed by a release.
+///
+///
+public static class TagCipher
+{
+ private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.Tag;
+
+ /// Encrypts a tag.
+ /// The tag. Must be valid for storage.
+ /// The vault key, which the data key is wrapped under.
+ /// The item id, which the AAD binds.
+ /// The vault's current key generation.
+ /// The version this payload will hold once the server accepts it.
+ public static EncryptedPayload Seal(
+ TagSecret tag,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ uint keyGeneration,
+ int itemVersion)
+ {
+ ArgumentNullException.ThrowIfNull(tag);
+ ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
+
+ var plaintext = TagSecretCodec.Encode(tag);
+ var dataKey = ItemKeys.CreateDataKey();
+
+ try
+ {
+ var dataKeyId = Guid.CreateVersion7();
+
+ var wrappedDataKey = ItemKeys.WrapDataKey(
+ dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
+
+ var envelope = ItemKeys.SealPayload(
+ dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
+
+ return new EncryptedPayload(
+ envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(dataKey);
+
+ // Wiped like every other payload in this folder, and here for the smallest reason of all: the
+ // buffer holds one word somebody chose for a filter. It is wiped anyway, because the rule this
+ // folder follows is that plaintext does not outlive the call that made it, and an exception for
+ // the case that seems harmless is how the rule stops being one.
+ CryptographicOperations.ZeroMemory(plaintext);
+ }
+ }
+
+ /// Decrypts a tag.
+ ///
+ public static TagSecretDocument? TryOpen(
+ EncryptedPayload payload,
+ ReadOnlySpan vaultKey,
+ Guid entityId,
+ int itemVersion)
+ {
+ ArgumentNullException.ThrowIfNull(payload);
+
+ if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
+ {
+ return null;
+ }
+
+ var dataKey = ItemKeys.TryUnwrapDataKey(
+ vaultKey,
+ payload.WrappedDataKey,
+ Resource,
+ entityId,
+ payload.KeyGeneration,
+ (uint)itemVersion);
+
+ if (dataKey is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ var plaintext = ItemKeys.TryOpenPayload(
+ dataKey,
+ payload.Envelope,
+ Resource,
+ entityId,
+ payload.DataKeyId,
+ payload.KeyGeneration,
+ (uint)itemVersion);
+
+ if (plaintext is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ return TagSecretCodec.TryDecode(plaintext, out var document) ? document : null;
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(plaintext);
+ }
+ }
+ finally
+ {
+ CryptographicOperations.ZeroMemory(dataKey);
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.Sync/TagRepository.cs b/src/DodoSSH.Client.Sync/TagRepository.cs
new file mode 100644
index 0000000..342b213
--- /dev/null
+++ b/src/DodoSSH.Client.Sync/TagRepository.cs
@@ -0,0 +1,58 @@
+using DodoSSH.Client.Domain;
+using DodoSSH.Client.Storage;
+
+namespace DodoSSH.Client.Sync;
+
+///
+/// The tags in this vault, decrypted, with unpushed local changes laid over them.
+///
+///
+///
+/// Another facade over the same generic repository, and like the ones before it, it needed no new sync logic
+/// at all.
+///
+///
+/// Deleting a tag does not touch the hosts wearing it, for the reason
+/// gives about groups and one more besides. The shared reason: one user
+/// action would become N host writes, N outbox rows and N chances to merge against an edit nobody made, and
+/// the tag's own tombstone can still lose a merge — by which time the membership it was clearing is gone.
+/// The extra one: a host can wear several tags, so unpicking one from N hosts is N read-modify-writes of a
+/// set rather than N clears of a pointer, and any of them that loses a merge leaves the vault holding a tag
+/// that was deleted and is still worn. Hosts left holding a dangling id simply do not show that chip. See
+/// .
+///
+///
+public sealed class TagRepository(
+ ItemStore items,
+ OutboxStore outbox,
+ VaultKeyring keyring,
+ IActivityLogSink? activity = null)
+{
+ private readonly VaultItemRepository tags =
+ new(TagKind.Instance, items, outbox, keyring, activity);
+
+ ///
+ public Task> ListAsync(
+ Guid vaultId,
+ CancellationToken cancellationToken) =>
+ tags.ListAsync(vaultId, cancellationToken);
+
+ ///
+ public Task CreateAsync(
+ Guid vaultId,
+ TagSecret tag,
+ CancellationToken cancellationToken) =>
+ tags.CreateAsync(vaultId, tag, cancellationToken);
+
+ ///
+ public Task UpdateAsync(
+ Guid vaultId,
+ Guid entityId,
+ TagSecret tag,
+ CancellationToken cancellationToken) =>
+ tags.UpdateAsync(vaultId, entityId, tag, cancellationToken);
+
+ ///
+ public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
+ tags.DeleteAsync(vaultId, entityId, cancellationToken);
+}
diff --git a/src/DodoSSH.Domain/Hosts.cs b/src/DodoSSH.Domain/Hosts.cs
index 6760e15..777bc88 100644
--- a/src/DodoSSH.Domain/Hosts.cs
+++ b/src/DodoSSH.Domain/Hosts.cs
@@ -327,15 +327,25 @@ public sealed class VaultKnownHostKey : IVaultItem
///
///
///
-/// A group is a name and nothing else, so this row is the narrowest one in the schema: an envelope and its
-/// bookkeeping. There is no parent_id and no name column, and both absences are deliberate.
+/// Everything a group is — its name, the group it sits under, and the port, username and binding hosts
+/// inside it fall back to — is inside the envelope, so this row is one of the narrowest in the schema: an
+/// envelope and its bookkeeping. There is no parent_id and no name column, and both absences
+/// are deliberate.
///
///
-/// No parent, because groups are flat. A nesting pointer merged by a scalar three-way merge lets two
-/// offline clients each re-parent A under B and B under A, and the result is a cycle the server cannot see —
-/// the pointer would be inside the payload, which the server cannot read — and which every client would then
-/// have to detect on every read, forever. Flat costs one level of organisation and removes a whole class of
-/// unrepairable state.
+/// No parent column, although groups do nest. The pointer exists; it lives in the payload, and the
+/// push path refuses a plaintext ParentId outright. What a column here would hand the operator is the
+/// shape of every user's estate — how many groupings, how deep, which under which — which is the same
+/// disclosure a plaintext group_id on a host would have been, and ADR 0004 spends the one plaintext
+/// concession this design allows on the relay address instead.
+///
+///
+/// The consequence is that the server cannot see a cycle, and it was once the argument for having no parent
+/// at all: two offline clients can each re-parent A under B and B under A, a scalar merge accepts both, and
+/// nothing server-side can refuse the pair. That is still true and is now handled rather than avoided —
+/// every walk of the chain carries a visited set and stops at a repeat, so a cycle degrades to a group that
+/// reads as a root. See HostGroupSecret on the client, which is the only thing that can read any of
+/// this.
///
///
/// No name, for the reason has no host column. A group name is not
@@ -407,6 +417,81 @@ public sealed class VaultHostGroup : IVaultItem
public Guid UpdatedByUserId { get; set; }
}
+///
+/// A label that can be put on many hosts, as ciphertext.
+///
+///
+///
+/// 's shape exactly — an envelope and its bookkeeping — and the reasoning for the
+/// missing name column is the same argument at higher stakes. A group name describes how one user
+/// files their machines; a tag name describes what the machines are, and one tag spans twenty hosts
+/// by design. A column here would give the operator a labelled map of every estate on the server, to sort a
+/// list nothing server-side draws.
+///
+///
+/// No membership, and no join table either. Which hosts wear this tag is a set of ids inside each
+/// host's payload. SyncEntityType.HostTag reserves a slot for the join and it stays reserved: the one
+/// thing a join buys over a set on the host — two people tagging the same host without one of them losing —
+/// is already bought by the client's per-key three-way merge, and a second item type is a second table, a
+/// second migration and a second pass through every rule in this file for nothing.
+///
+///
+public sealed class VaultTag : IVaultItem
+{
+ /// Primary key. UUIDv7, generated by the client so a tag can be created offline.
+ public Guid Id { get; set; }
+
+ /// Owning vault.
+ public Guid VaultId { get; set; }
+
+ /// Owning vault.
+ public Vault? Vault { get; set; }
+
+ /// The encrypted tag: a DSH1 envelope. Opaque to the server.
+ public byte[] Payload { get; set; } = [];
+
+ /// The item's data key, wrapped under the vault key. Opaque.
+ public byte[]? DataKeyWrap { get; set; }
+
+ /// Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.
+ public Guid? ContentKeyId { get; set; }
+
+ /// Vault key generation this payload was encrypted under.
+ public int KeyGeneration { get; set; }
+
+ /// AAD rule version, enabling a lazy re-encrypt-on-write migration later.
+ public short PayloadAadVersion { get; set; }
+
+ /// Client-visible, monotonic item version, used for expectedVersion checks.
+ public int Version { get; set; }
+
+ /// Latest change-log sequence touching this row, so a delta pull can join directly.
+ public long ChangeSequence { get; set; }
+
+ /// Creation timestamp.
+ public DateTimeOffset CreatedAtUtc { get; set; }
+
+ /// Last modification timestamp.
+ public DateTimeOffset UpdatedAtUtc { get; set; }
+
+ ///
+ /// Soft-delete marker; a tombstone, so an offline client learns the tag went away.
+ ///
+ ///
+ /// Deleting a tag leaves every host wearing it holding an id that resolves to nothing, and that is the
+ /// intended outcome: those hosts simply stop drawing that chip. Rewriting N host payloads inside one
+ /// delete would turn a single user action into N pushes, and each of those is a read-modify-write of a
+ /// set that can lose a merge — which would leave the vault holding a tag that is deleted and still worn.
+ ///
+ public DateTimeOffset? DeletedAtUtc { get; set; }
+
+ /// Who created it.
+ public Guid CreatedByUserId { get; set; }
+
+ /// Who last modified it.
+ public Guid UpdatedByUserId { get; set; }
+}
+
///
/// An S3-compatible bucket and the credentials that reach it, as ciphertext.
///
diff --git a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs
index 857b9bc..df208d9 100644
--- a/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs
+++ b/src/DodoSSH.Infrastructure/Configurations/HostAndSyncConfigurations.cs
@@ -164,11 +164,12 @@ public sealed class KnownHostKeyConfiguration : IEntityTypeConfiguration.
///
///
-/// The same shape again, and by now the sameness is the design rather than a coincidence: four item types
-/// hold nothing but an envelope and its bookkeeping. This one is worth a sentence anyway, because it is the
-/// type where a plaintext column would have been most tempting and least defensible — a name here
-/// would let the server order a list it never draws, in exchange for telling the operator how every user
-/// files their machines.
+/// The same shape again, and by now the sameness is the design rather than a coincidence: most item types
+/// hold nothing but an envelope and its bookkeeping. This one is worth a sentence anyway, because it is a
+/// type where plaintext columns would have been tempting and are not defensible — a name here would
+/// let the server order a list it never draws, and a parent_id would draw the shape of every user's
+/// estate, both in exchange for telling the operator how each of them files their machines. Groups nest
+/// through a pointer inside the payload; nothing about that is visible from this table.
///
public sealed class HostGroupConfiguration : IEntityTypeConfiguration
{
@@ -199,6 +200,43 @@ public sealed class HostGroupConfiguration : IEntityTypeConfiguration
+/// Maps .
+///
+///
+/// again, down to the index names. The one thing worth stating is what
+/// is not here: no host_tag join table, because membership is a set inside each host's
+/// payload — see — so there is nothing to join and no second table to keep in step.
+///
+public sealed class TagConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ builder.ToTable("tag");
+ builder.HasKey(t => t.Id);
+
+ // Client-generated UUIDv7: a tag must be creatable offline, with its id, because the host that
+ // wears it is created offline too and needs something to point at.
+ builder.Property(t => t.Id).ValueGeneratedNever();
+ builder.UseXminConcurrencyToken();
+
+ builder.Property(t => t.Payload).IsRequired();
+
+ builder.HasIndex(t => new { t.VaultId, t.ChangeSequence });
+
+ builder.HasIndex(t => t.VaultId)
+ .HasFilter("deleted_at_utc IS NULL")
+ .HasDatabaseName("ix_tag_vault_live");
+
+ builder.ToTable(t => t.HasCheckConstraint(
+ "ck_tag_version",
+ "version >= 1"));
+ }
+}
+
///
/// Maps .
///
diff --git a/src/DodoSSH.Infrastructure/DodoDbContext.cs b/src/DodoSSH.Infrastructure/DodoDbContext.cs
index 14b0bde..e59cc67 100644
--- a/src/DodoSSH.Infrastructure/DodoDbContext.cs
+++ b/src/DodoSSH.Infrastructure/DodoDbContext.cs
@@ -66,6 +66,9 @@ public class DodoDbContext(DbContextOptions options) : DbContext(
/// Host groups, held as ciphertext.
public DbSet HostGroups => Set();
+ /// Tags hosts can wear, held as ciphertext.
+ public DbSet Tags => Set();
+
/// Saved commands, held as ciphertext.
public DbSet Snippets => Set();
diff --git a/src/DodoSSH.Infrastructure/Migrations/20260803072550_AddTagItem.Designer.cs b/src/DodoSSH.Infrastructure/Migrations/20260803072550_AddTagItem.Designer.cs
new file mode 100644
index 0000000..83b0371
--- /dev/null
+++ b/src/DodoSSH.Infrastructure/Migrations/20260803072550_AddTagItem.Designer.cs
@@ -0,0 +1,1809 @@
+//
+using System;
+using DodoSSH.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace DodoSSH.Infrastructure.Migrations
+{
+ [DbContext(typeof(DodoDbContext))]
+ [Migration("20260803072550_AddTagItem")]
+ partial class AddTagItem
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("dodo")
+ .HasAnnotation("ProductVersion", "10.0.10")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "citext");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("DodoSSH.Domain.Device", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("EnrolledAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("enrolled_at_utc");
+
+ b.Property("LastSeenAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at_utc");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("name");
+
+ b.Property("Platform")
+ .HasColumnType("integer")
+ .HasColumnName("platform");
+
+ b.Property("PublicKey")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("public_key");
+
+ b.Property("RevokedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at_utc");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_device");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_device_user_id");
+
+ b.ToTable("device", "dodo");
+ });
+
+ 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")
+ .HasColumnName("id");
+
+ b.Property("ChangeSequence")
+ .HasColumnType("bigint")
+ .HasColumnName("change_sequence");
+
+ b.Property("ContentKeyId")
+ .HasColumnType("uuid")
+ .HasColumnName("content_key_id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_user_id");
+
+ b.Property("DataKeyWrap")
+ .HasColumnType("bytea")
+ .HasColumnName("data_key_wrap");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("Hostname")
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("hostname");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("integer")
+ .HasColumnName("key_generation");
+
+ b.Property("Payload")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("payload");
+
+ b.Property("PayloadAadVersion")
+ .HasColumnType("smallint")
+ .HasColumnName("payload_aad_version");
+
+ b.Property("Port")
+ .HasColumnType("integer")
+ .HasColumnName("port");
+
+ b.Property("RelayEnabled")
+ .HasColumnType("boolean")
+ .HasColumnName("relay_enabled");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("UpdatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("updated_by_user_id");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.Property("Version")
+ .HasColumnType("integer")
+ .HasColumnName("version");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_host");
+
+ b.HasIndex("VaultId")
+ .HasDatabaseName("ix_host_vault_live")
+ .HasFilter("deleted_at_utc IS NULL");
+
+ b.HasIndex("VaultId", "ChangeSequence")
+ .HasDatabaseName("ix_host_vault_id_change_sequence");
+
+ b.ToTable("host", "dodo", t =>
+ {
+ t.HasCheckConstraint("ck_host_port_range", "port IS NULL OR (port BETWEEN 1 AND 65535)");
+
+ t.HasCheckConstraint("ck_host_relay_target", "(relay_enabled AND hostname IS NOT NULL AND port IS NOT NULL)\nOR (NOT relay_enabled AND hostname IS NULL AND port IS NULL)");
+
+ t.HasCheckConstraint("ck_host_version", "version >= 1");
+ });
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.SyncOperationReceipt", b =>
+ {
+ b.Property("OperationId")
+ .HasColumnType("uuid")
+ .HasColumnName("operation_id");
+
+ b.Property("AppliedChangeSequence")
+ .HasColumnType("bigint")
+ .HasColumnName("applied_change_sequence");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("ResultVersion")
+ .HasColumnType("integer")
+ .HasColumnName("result_version");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.HasKey("OperationId")
+ .HasName("pk_sync_operation_receipt");
+
+ b.HasIndex("VaultId", "CreatedAtUtc")
+ .HasDatabaseName("ix_sync_operation_receipt_vault_id_created_at_utc");
+
+ b.ToTable("sync_operation_receipt", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.Team", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_user_id");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("Description")
+ .HasMaxLength(2048)
+ .HasColumnType("character varying(2048)")
+ .HasColumnName("description");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("name");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("citext")
+ .HasColumnName("slug");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_team");
+
+ b.HasIndex("Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_team_slug")
+ .HasFilter("deleted_at_utc IS NULL");
+
+ b.ToTable("team", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("InvitedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("invited_by_user_id");
+
+ b.Property("JoinedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("joined_at_utc");
+
+ b.Property("Role")
+ .HasColumnType("integer")
+ .HasColumnName("role");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.Property("TeamId")
+ .HasColumnType("uuid")
+ .HasColumnName("team_id");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_team_membership");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_team_membership_user_id");
+
+ b.HasIndex("TeamId", "UserId")
+ .IsUnique()
+ .HasDatabaseName("ix_team_membership_team_id_user_id")
+ .HasFilter("deleted_at_utc IS NULL");
+
+ b.ToTable("team_membership", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.UserAccount", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("DisplayName")
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("display_name");
+
+ b.Property("Email")
+ .HasMaxLength(320)
+ .HasColumnType("citext")
+ .HasColumnName("email");
+
+ b.Property("EnrolledAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("enrolled_at_utc");
+
+ b.Property("Issuer")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("character varying(512)")
+ .HasColumnName("issuer");
+
+ b.Property("LastSeenAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_seen_at_utc");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.Property("Subject")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("subject");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_user_account");
+
+ b.HasIndex("Email")
+ .IsUnique()
+ .HasDatabaseName("ix_user_account_email")
+ .HasFilter("email IS NOT NULL AND deleted_at_utc IS NULL");
+
+ b.HasIndex("Issuer", "Subject")
+ .IsUnique()
+ .HasDatabaseName("ix_user_account_issuer_subject");
+
+ b.ToTable("user_account", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.UserKey", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ 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("FingerprintSha256")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("fingerprint_sha256");
+
+ b.Property("Generation")
+ .HasColumnType("integer")
+ .HasColumnName("generation");
+
+ b.Property("IdentityProviderBinding")
+ .HasColumnType("jsonb")
+ .HasColumnName("identity_provider_binding");
+
+ b.Property("IsCurrent")
+ .HasColumnType("boolean")
+ .HasColumnName("is_current");
+
+ b.Property("RevokedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at_utc");
+
+ b.Property("SigningPublicKey")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("signing_public_key");
+
+ b.Property("Statement")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("statement");
+
+ b.Property("StatementSignature")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("bytea")
+ .HasColumnName("statement_signature");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_user_key");
+
+ b.HasIndex("FingerprintSha256")
+ .IsUnique()
+ .HasDatabaseName("ix_user_key_fingerprint_sha256");
+
+ b.HasIndex("UserId")
+ .IsUnique()
+ .HasDatabaseName("ix_user_key_current")
+ .HasFilter("is_current");
+
+ b.HasIndex("UserId", "Generation")
+ .IsUnique()
+ .HasDatabaseName("ix_user_key_user_id_generation");
+
+ b.ToTable("user_key", "dodo");
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.UserKeyWrap", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("DeviceId")
+ .HasColumnType("uuid")
+ .HasColumnName("device_id");
+
+ b.Property("KdfAlgorithm")
+ .HasMaxLength(64)
+ .HasColumnType("character varying(64)")
+ .HasColumnName("kdf_algorithm");
+
+ b.Property("KdfMemoryKibibytes")
+ .HasColumnType("integer")
+ .HasColumnName("kdf_memory_kibibytes");
+
+ b.Property("KdfParallelism")
+ .HasColumnType("integer")
+ .HasColumnName("kdf_parallelism");
+
+ b.Property("KdfPasses")
+ .HasColumnType("integer")
+ .HasColumnName("kdf_passes");
+
+ b.Property("KdfSalt")
+ .HasMaxLength(64)
+ .HasColumnType("bytea")
+ .HasColumnName("kdf_salt");
+
+ b.Property("Kind")
+ .HasColumnType("integer")
+ .HasColumnName("kind");
+
+ b.Property("LastUsedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_used_at_utc");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("Wrap")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("wrap");
+
+ b.Property("WrapVersion")
+ .HasColumnType("integer")
+ .HasColumnName("wrap_version");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_user_key_wrap");
+
+ b.HasIndex("DeviceId")
+ .HasDatabaseName("ix_user_key_wrap_device_id");
+
+ b.HasIndex("UserId", "DeviceId")
+ .IsUnique()
+ .HasDatabaseName("ix_user_key_wrap_user_device")
+ .HasFilter("device_id IS NOT NULL");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasDatabaseName("ix_user_key_wrap_user_kind")
+ .HasFilter("device_id IS NULL");
+
+ b.ToTable("user_key_wrap", "dodo", t =>
+ {
+ t.HasCheckConstraint("ck_user_key_wrap_device", "(kind = 2 AND device_id IS NOT NULL) OR (kind <> 2 AND device_id IS NULL)");
+
+ t.HasCheckConstraint("ck_user_key_wrap_kdf", "(kind IN (1, 3) AND kdf_algorithm IS NOT NULL AND kdf_salt IS NOT NULL\n AND kdf_memory_kibibytes IS NOT NULL AND kdf_passes IS NOT NULL\n AND kdf_parallelism IS NOT NULL)\nOR (kind IN (2, 4) AND kdf_algorithm IS NULL AND kdf_salt IS NULL)");
+ });
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.Vault", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("integer")
+ .HasColumnName("key_generation");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("character varying(256)")
+ .HasColumnName("name");
+
+ b.Property("OwnerKind")
+ .HasColumnType("integer")
+ .HasColumnName("owner_kind");
+
+ b.Property("OwnerUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("owner_user_id");
+
+ b.Property("RekeyReason")
+ .HasColumnType("integer")
+ .HasColumnName("rekey_reason");
+
+ b.Property("RekeyRequired")
+ .HasColumnType("boolean")
+ .HasColumnName("rekey_required");
+
+ b.Property("TeamId")
+ .HasColumnType("uuid")
+ .HasColumnName("team_id");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_vault");
+
+ b.HasIndex("OwnerUserId")
+ .HasDatabaseName("ix_vault_owner_user_id");
+
+ b.HasIndex("TeamId")
+ .HasDatabaseName("ix_vault_team_id");
+
+ b.ToTable("vault", "dodo", t =>
+ {
+ t.HasCheckConstraint("ck_vault_key_generation", "key_generation >= 1");
+
+ t.HasCheckConstraint("ck_vault_owner", "(owner_kind = 1 AND owner_user_id IS NOT NULL AND team_id IS NULL)\nOR (owner_kind = 2 AND team_id IS NOT NULL AND owner_user_id IS NULL)");
+ });
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.VaultActivityLogEntry", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ChangeSequence")
+ .HasColumnType("bigint")
+ .HasColumnName("change_sequence");
+
+ b.Property("ContentKeyId")
+ .HasColumnType("uuid")
+ .HasColumnName("content_key_id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_user_id");
+
+ b.Property("DataKeyWrap")
+ .HasColumnType("bytea")
+ .HasColumnName("data_key_wrap");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("integer")
+ .HasColumnName("key_generation");
+
+ b.Property("Payload")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("payload");
+
+ b.Property("PayloadAadVersion")
+ .HasColumnType("smallint")
+ .HasColumnName("payload_aad_version");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("UpdatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("updated_by_user_id");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.Property("Version")
+ .HasColumnType("integer")
+ .HasColumnName("version");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_activity_log_entry");
+
+ b.HasIndex("VaultId")
+ .HasDatabaseName("ix_activity_log_entry_vault_live")
+ .HasFilter("deleted_at_utc IS NULL");
+
+ b.HasIndex("VaultId", "ChangeSequence")
+ .HasDatabaseName("ix_activity_log_entry_vault_id_change_sequence");
+
+ b.ToTable("activity_log_entry", "dodo", t =>
+ {
+ t.HasCheckConstraint("ck_activity_log_entry_version", "version >= 1");
+ });
+ });
+
+ 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.VaultConnectionLogEntry", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ChangeSequence")
+ .HasColumnType("bigint")
+ .HasColumnName("change_sequence");
+
+ b.Property("ContentKeyId")
+ .HasColumnType("uuid")
+ .HasColumnName("content_key_id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_user_id");
+
+ b.Property("DataKeyWrap")
+ .HasColumnType("bytea")
+ .HasColumnName("data_key_wrap");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("integer")
+ .HasColumnName("key_generation");
+
+ b.Property("Payload")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("payload");
+
+ b.Property("PayloadAadVersion")
+ .HasColumnType("smallint")
+ .HasColumnName("payload_aad_version");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("UpdatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("updated_by_user_id");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.Property("Version")
+ .HasColumnType("integer")
+ .HasColumnName("version");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_connection_log_entry");
+
+ b.HasIndex("VaultId")
+ .HasDatabaseName("ix_connection_log_entry_vault_live")
+ .HasFilter("deleted_at_utc IS NULL");
+
+ b.HasIndex("VaultId", "ChangeSequence")
+ .HasDatabaseName("ix_connection_log_entry_vault_id_change_sequence");
+
+ b.ToTable("connection_log_entry", "dodo", t =>
+ {
+ t.HasCheckConstraint("ck_connection_log_entry_version", "version >= 1");
+ });
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.VaultCredential", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ChangeSequence")
+ .HasColumnType("bigint")
+ .HasColumnName("change_sequence");
+
+ b.Property("ContentKeyId")
+ .HasColumnType("uuid")
+ .HasColumnName("content_key_id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_user_id");
+
+ b.Property("DataKeyWrap")
+ .HasColumnType("bytea")
+ .HasColumnName("data_key_wrap");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("integer")
+ .HasColumnName("key_generation");
+
+ b.Property("Payload")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("payload");
+
+ b.Property("PayloadAadVersion")
+ .HasColumnType("smallint")
+ .HasColumnName("payload_aad_version");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("UpdatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("updated_by_user_id");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.Property("Version")
+ .HasColumnType("integer")
+ .HasColumnName("version");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_credential");
+
+ b.HasIndex("VaultId")
+ .HasDatabaseName("ix_credential_vault_live")
+ .HasFilter("deleted_at_utc IS NULL");
+
+ b.HasIndex("VaultId", "ChangeSequence")
+ .HasDatabaseName("ix_credential_vault_id_change_sequence");
+
+ b.ToTable("credential", "dodo", t =>
+ {
+ t.HasCheckConstraint("ck_credential_version", "version >= 1");
+ });
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.VaultHostGroup", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ChangeSequence")
+ .HasColumnType("bigint")
+ .HasColumnName("change_sequence");
+
+ b.Property("ContentKeyId")
+ .HasColumnType("uuid")
+ .HasColumnName("content_key_id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("CreatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("created_by_user_id");
+
+ b.Property("DataKeyWrap")
+ .HasColumnType("bytea")
+ .HasColumnName("data_key_wrap");
+
+ b.Property("DeletedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("deleted_at_utc");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("integer")
+ .HasColumnName("key_generation");
+
+ b.Property("Payload")
+ .IsRequired()
+ .HasColumnType("bytea")
+ .HasColumnName("payload");
+
+ b.Property("PayloadAadVersion")
+ .HasColumnType("smallint")
+ .HasColumnName("payload_aad_version");
+
+ b.Property("UpdatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at_utc");
+
+ b.Property("UpdatedByUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("updated_by_user_id");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.Property("Version")
+ .HasColumnType("integer")
+ .HasColumnName("version");
+
+ b.Property("xmin")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("xid")
+ .HasColumnName("xmin");
+
+ b.HasKey("Id")
+ .HasName("pk_host_group");
+
+ b.HasIndex("VaultId")
+ .HasDatabaseName("ix_host_group_vault_live")
+ .HasFilter("deleted_at_utc IS NULL");
+
+ b.HasIndex("VaultId", "ChangeSequence")
+ .HasDatabaseName("ix_host_group_vault_id_change_sequence");
+
+ b.ToTable("host_group", "dodo", t =>
+ {
+ t.HasCheckConstraint("ck_host_group_version", "version >= 1");
+ });
+ });
+
+ modelBuilder.Entity("DodoSSH.Domain.VaultKeyGrant", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at_utc");
+
+ b.Property("GranterKeyFingerprint")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("granter_key_fingerprint");
+
+ b.Property("GranterUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("granter_user_id");
+
+ b.Property("KeyGeneration")
+ .HasColumnType("integer")
+ .HasColumnName("key_generation");
+
+ b.Property("KeyLogHead")
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("key_log_head");
+
+ b.Property("Kind")
+ .HasColumnType("integer")
+ .HasColumnName("kind");
+
+ b.Property("RecipientKeyFingerprint")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("bytea")
+ .HasColumnName("recipient_key_fingerprint");
+
+ b.Property("RecipientUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("recipient_user_id");
+
+ b.Property("RevokedAtUtc")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("revoked_at_utc");
+
+ b.Property("Signature")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("bytea")
+ .HasColumnName("signature");
+
+ b.Property("State")
+ .HasColumnType("integer")
+ .HasColumnName("state");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid")
+ .HasColumnName("vault_id");
+
+ b.Property