Public Access
Build the three things the phone's + needs, before the + exists
Steps 1 to 3 of docs/adding-hosts-on-the-phone.md: the domain half. Nothing on either head has changed, which is deliberate — the plan orders these first because everything the editors will bind to has to exist and be merge-safe before a screen can offer it. HostGroupSecret gains a parent and four defaults, and the codec gains the version rule it never had. It stamped CurrentSchemaVersion unconditionally, which was harmless with one field and one version and stops being harmless here: upgrading one machine and renaming any group would have made that group uneditable on every machine still on the old build. It now emits the lowest version that loses nothing, so a flat group with no defaults still encodes at version 1, byte for byte, pinned against a literal. Tags become a real item over the reserved slot. Secret, codec, merge, cipher, repository, both registries, the EF entity and a generated AddTagItem migration. TagCipher names AadResourceType.Tag as a constant rather than casting the wire type, because Tag is 5 on the wire and 8 in the crypto enum and 5 there is Credential — a cast would seal every tag under the resource type for a password, encrypt and decrypt perfectly on the machine that wrote it, and only fail when another implementation refused the item, by which time the AAD is frozen into stored ciphertext. HostTag stays reserved and unused: the one thing the join buys over a set on the host is bought instead by merging TagIds per id. HostSecret grows TagIds and Port goes nullable, which is the change with the widest blast radius and the only one that loses an item rather than locking one. A host with no port of its own omits the property, an older build reads int Port as 0, and TryValidate refuses it — unreadable rather than read-only. That cost is confined to hosts which actually inherit, because the version is a maximum over the fields present; the alternative, writing 22 into every host, is the lie inheritance exists to stop telling. One decision the plan did not specify. "Three states where there were two" is four — key, credential, typed password, or the group's answer — and two nullable ids carry three. Naming neither id now means inherit, so AsksForPassword says "a typed password even under a group that lends a key" out loud. Only true is ever written and a decoded false folds back to null, so a host that never touched it encodes as it always did. Nothing already stored changed meaning: no group could lend a binding before this build, so every existing host resolves exactly as it did. HostInheritance is the resolver, and its visited set is load-bearing rather than defensive. Two clients can each re-parent A under B and B under A while offline; the merge sees one item against one item and the server sees ciphertext, so nothing upstream can refuse the pair. With inheritance the chain is walked at connect time, so an unguarded cycle is not an undrawable sidebar — it is a shell that never opens. Stopping at the first repeat degrades it to a group that reads as a root, and clearing the parent is the repair. A tag set turns out to be the one field on a host that can never ask the user anything. TagSet.ToIdMap keys by the value, so no key can hold two values, so the both-sides-moved-differently branch of the keyed merge is unreachable — asserted over the whole eight-row matrix. The conflict loop is kept anyway, because that proof is one edit from ceasing to hold and what it would cause is a discarded tag nothing records. Three guard tests failed by design and were fixed rather than relaxed: the ordered pull filter, the AAD pinning table, and the server's refusal of a plaintext parent — that last one survives with its reason rewritten, because the refusal now means "the parent is not the server's to hold" rather than "there is no such thing as a parent". The prose that said groups are flat is rewritten in all four places it appeared, not deleted. The five view-model sites that read Port directly now go through the resolver, which is a down payment on step 4 rather than the whole of it. HostFields.From still emits the stored port, and that is the one remaining place where an unresolved read would be a wrong wire rather than a wrong label. Verified by the whole suite: 1382 tests over nineteen projects, none failing. Both heads build. Nothing seen on a display, because nothing on a display has changed yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A <c>GroupId</c> on a group would be a parent pointer, and groups are flat — see
|
||||
/// <see cref="VaultHostGroup"/> 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.
|
||||
/// <b>Groups nest, and the pointer that nests them is still refused here.</b> 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
|
||||
/// <c>ParentId</c> or <c>GroupId</c> 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.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Tags: an envelope and nothing else.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="HostGroupKind"/>'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.
|
||||
/// </remarks>
|
||||
internal sealed class TagKind : IItemKind
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public SyncEntityType WireType => SyncEntityType.Tag;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChangeEntityType ChangeType => ChangeEntityType.Tag;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IVaultItem?> FindAsync(
|
||||
DodoDbContext database,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
await database.Tags.SingleOrDefaultAsync(t => t.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<Guid, IVaultItem>> 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
|
||||
{
|
||||
var tag = new VaultTag { Id = id, VaultId = vaultId };
|
||||
|
||||
database.Tags.Add(tag);
|
||||
|
||||
return tag;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses every plaintext field there is, including the one a join table would have reached for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b><c>RelatedId</c> matters more here than for any other kind.</b> It is the obvious place to put "the
|
||||
/// host this tag is on", which is exactly what <c>SyncEntityType.HostTag</c> 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. <c>SyncPlaintextFields</c> is frozen so the field
|
||||
/// cannot be removed; refusing it is the only place the decision can be enforced.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
||||
/// <inheritdoc />
|
||||
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearFieldsOnDelete(IVaultItem item)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
||||
}
|
||||
|
||||
/// <summary>Snippets: an envelope and nothing else.</summary>
|
||||
/// <remarks>
|
||||
/// A label column here would sort a list this server never draws, and the commands beside that label describe
|
||||
|
||||
Reference in New Issue
Block a user