Public Access
Freeze DodoSSH.Contracts v0.1 (M1)
The second M1 gate. This assembly, not the OpenAPI document, is the client's contract, so PublicApiAnalyzers now tracks all 540 public members: a renamed DTO property becomes a build error rather than a runtime deserialisation failure on someone's laptop. Contract surface: - EncryptedPayload carries the envelope plus the KeyGeneration and AadVersion columns needed to recompute AAD, since AAD is derived from the row rather than transmitted. - Sync: push with per-operation status (Applied/Conflict/Forbidden/Invalid/Duplicate) so one stale item cannot block a whole offline queue; a Conflict returns the server's row for client-side three-way merge, because the server cannot merge ciphertext. - Enrollment: KeyStatement whose hash becomes the OIDC nonce, so the identity provider signs over the public keys and this server cannot fabricate a key for a user who never enrolled. - Meta and .well-known configuration: capability negotiation instead of URL versioning, which is what a self-hosted product needs when client and server upgrade independently. - SyncPlaintextFields deliberately has no label or name field. ACL admin runs client-side where names can be decrypted, so the server never needs a searchable title. Two design problems found by writing the tests rather than assuming: - Hand-constructing JsonSerializerOptions and merely pointing its resolver at the context silently discards every source-generated setting. JsonSerializerDefaults.Web replaces NumberHandling.Strict with AllowReadingFromString, so "1" would be accepted where 1 is meant — invisible until two implementations disagree. Callers now use ResponseOptions or StrictRequestOptions; StrictRequestOptions is derived by copying so it cannot drift. - StrictRequestOptions had a static-initialisation cycle: it read the generated Default property from the same type's initialiser and got null. Now lazy. Requests reject unmapped members so a client typo is a 400; responses tolerate them so an older client can read a newer server. Enums cross the wire as strings, so reordering one cannot silently reinterpret stored data. Also: excluded source-generator output from PublicApiAnalyzers. The JSON generator emits a public member per serialisable type, which would have added hundreds of mechanical entries and drowned the ones describing the actual wire contract. And disabled MA0048's one-type-per-file rule: splitting SyncPullRequest from SyncPullResponse makes a reviewer open two files to understand one endpoint. Verified: 0 warnings, 95 tests pass, format clean.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
namespace DodoSSH.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Plaintext columns that the server needs in order to function, supplied alongside a payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Kept deliberately minimal. There is no plaintext label or name: ACL administration happens
|
||||
/// in the client, which can decrypt, so the server never needs a searchable title.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Hostname"/> and <see cref="Port"/> may only be set when <see cref="RelayEnabled"/>
|
||||
/// is true, and the database enforces that with a CHECK constraint. The relay must resolve its
|
||||
/// target server-side or it becomes an authenticated open TCP proxy into the operator's own
|
||||
/// network; see ADR 0004. Everything else about a host — username, notes, jump chain, options —
|
||||
/// stays inside the encrypted payload.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="RelayEnabled">Whether this host may be dialled through the server relay.</param>
|
||||
/// <param name="Hostname">Target hostname. Permitted only when relay is enabled.</param>
|
||||
/// <param name="Port">Target port. Permitted only when relay is enabled.</param>
|
||||
/// <param name="GroupId">Owning group, for tree placement.</param>
|
||||
/// <param name="ParentId">Parent entity, for associations and nested groups.</param>
|
||||
/// <param name="RelatedId">The second side of an association row.</param>
|
||||
/// <param name="Kind">Discriminator for entity types that have one, such as credential kind.</param>
|
||||
/// <param name="PublicKeyFingerprint">
|
||||
/// Fingerprint of a public key. Deliberately plaintext: public keys are not secret, and this
|
||||
/// enables "which hosts trust this key" without weakening the threat model.
|
||||
/// </param>
|
||||
public sealed record SyncPlaintextFields(
|
||||
bool RelayEnabled = false,
|
||||
string? Hostname = null,
|
||||
int? Port = null,
|
||||
Guid? GroupId = null,
|
||||
Guid? ParentId = null,
|
||||
Guid? RelatedId = null,
|
||||
int? Kind = null,
|
||||
string? PublicKeyFingerprint = null);
|
||||
|
||||
/// <summary>
|
||||
/// One change a client wants to apply.
|
||||
/// </summary>
|
||||
/// <param name="OperationId">
|
||||
/// Client-generated idempotency key for this single operation. A retried push with the same id
|
||||
/// returns <see cref="SyncOperationStatus.Duplicate"/> rather than applying twice.
|
||||
/// </param>
|
||||
/// <param name="EntityType">Kind of item.</param>
|
||||
/// <param name="EntityId">
|
||||
/// Identifier, generated by the client with UUIDv7 so items can be created offline.
|
||||
/// </param>
|
||||
/// <param name="Operation">Upsert or delete.</param>
|
||||
/// <param name="ExpectedVersion">
|
||||
/// The version the client believes the server holds; <see langword="null"/> means create. A
|
||||
/// mismatch yields <see cref="SyncOperationStatus.Conflict"/> — never last-writer-wins.
|
||||
/// </param>
|
||||
/// <param name="Payload">Ciphertext. Required for an upsert, omitted for a delete.</param>
|
||||
/// <param name="PlaintextFields">Plaintext columns the server needs.</param>
|
||||
public sealed record SyncPushOperation(
|
||||
Guid OperationId,
|
||||
SyncEntityType EntityType,
|
||||
Guid EntityId,
|
||||
SyncOperation Operation,
|
||||
int? ExpectedVersion,
|
||||
EncryptedPayload? Payload,
|
||||
SyncPlaintextFields? PlaintextFields);
|
||||
|
||||
/// <summary>A batch of changes to apply to one vault.</summary>
|
||||
/// <param name="Operations">
|
||||
/// The batch. Capped by the server; see <see cref="MetaResponse.MaxOperationsPerPush"/>.
|
||||
/// </param>
|
||||
public sealed record SyncPushRequest(IReadOnlyList<SyncPushOperation> Operations);
|
||||
|
||||
/// <summary>Outcome of one operation in a push.</summary>
|
||||
/// <param name="OperationId">Echoes the request's operation id.</param>
|
||||
/// <param name="Status">What happened.</param>
|
||||
/// <param name="Version">The stored version after a successful apply.</param>
|
||||
/// <param name="ChangeSequence">The change-log sequence assigned, for cursor comparison.</param>
|
||||
/// <param name="ServerEntity">
|
||||
/// The server's current state, present only on <see cref="SyncOperationStatus.Conflict"/> so
|
||||
/// the client can perform a three-way merge and re-push.
|
||||
/// </param>
|
||||
/// <param name="Detail">Human-readable explanation for an invalid operation. Never a secret.</param>
|
||||
public sealed record SyncPushResult(
|
||||
Guid OperationId,
|
||||
SyncOperationStatus Status,
|
||||
int? Version,
|
||||
long? ChangeSequence,
|
||||
SyncChange? ServerEntity,
|
||||
string? Detail);
|
||||
|
||||
/// <summary>Per-operation outcomes for a push.</summary>
|
||||
/// <param name="Results">One entry per submitted operation, in request order.</param>
|
||||
/// <param name="Cursor">
|
||||
/// A cursor positioned after every change this push produced, so the client can continue
|
||||
/// pulling without re-reading its own writes.
|
||||
/// </param>
|
||||
public sealed record SyncPushResponse(
|
||||
IReadOnlyList<SyncPushResult> Results,
|
||||
string Cursor);
|
||||
|
||||
/// <summary>A request for changes since a cursor.</summary>
|
||||
/// <param name="Cursor">
|
||||
/// An opaque, integrity-tagged cursor from a previous response, or <see langword="null"/> to
|
||||
/// start from the beginning. Clients must never construct or modify one.
|
||||
/// </param>
|
||||
/// <param name="Limit">Maximum changes to return. The server clamps this.</param>
|
||||
/// <param name="EntityTypes">Optional filter. Empty or null means all types.</param>
|
||||
public sealed record SyncPullRequest(
|
||||
string? Cursor,
|
||||
int? Limit,
|
||||
IReadOnlyList<SyncEntityType>? EntityTypes);
|
||||
|
||||
/// <summary>One change as returned by a pull.</summary>
|
||||
/// <param name="EntityType">Kind of item.</param>
|
||||
/// <param name="EntityId">Identifier.</param>
|
||||
/// <param name="Operation">Upsert or delete. A delete carries no payload.</param>
|
||||
/// <param name="Version">Version after the change.</param>
|
||||
/// <param name="ChangeSequence">Position in the vault's change log.</param>
|
||||
/// <param name="Payload">Ciphertext, absent for a delete.</param>
|
||||
/// <param name="PlaintextFields">Plaintext columns, absent for a delete.</param>
|
||||
/// <param name="UpdatedAt">When the change was recorded.</param>
|
||||
public sealed record SyncChange(
|
||||
SyncEntityType EntityType,
|
||||
Guid EntityId,
|
||||
SyncOperation Operation,
|
||||
int Version,
|
||||
long ChangeSequence,
|
||||
EncryptedPayload? Payload,
|
||||
SyncPlaintextFields? PlaintextFields,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
/// <summary>A page of changes.</summary>
|
||||
/// <param name="Changes">Changes in ascending change-sequence order.</param>
|
||||
/// <param name="NextCursor">Cursor to pass to the following pull.</param>
|
||||
/// <param name="HasMore">Whether more changes are immediately available.</param>
|
||||
/// <param name="ServerTime">
|
||||
/// The server's clock, so a client can detect its own skew rather than mis-ordering local edits.
|
||||
/// </param>
|
||||
/// <param name="CurrentKeyGeneration">
|
||||
/// The vault's current key generation. A client seeing a generation ahead of its own knows a
|
||||
/// rekey happened and that it must fetch new grants.
|
||||
/// </param>
|
||||
public sealed record SyncPullResponse(
|
||||
IReadOnlyList<SyncChange> Changes,
|
||||
string NextCursor,
|
||||
bool HasMore,
|
||||
DateTimeOffset ServerTime,
|
||||
uint CurrentKeyGeneration);
|
||||
Reference in New Issue
Block a user