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:
2026-07-28 13:28:02 +02:00
parent b15af836a3
commit 06d04b490b
12 changed files with 1472 additions and 0 deletions
+17
View File
@@ -118,6 +118,11 @@ dotnet_diagnostic.CA1711.severity = none
# a non-issue in practice and it heavily constrains domain naming.
dotnet_diagnostic.CA1724.severity = none
# MA0048 requires one type per file. Good for large types, actively worse for small DTO
# clusters: splitting SyncPullRequest from SyncPullResponse means a reviewer opens two files
# to understand one endpoint's contract. The BCL groups related types the same way.
dotnet_diagnostic.MA0048.severity = none
# We use file-scoped namespaces and modern C#; these fire on deliberate style choices.
dotnet_diagnostic.CA1812.severity = none # internal types instantiated by DI
dotnet_diagnostic.CA1849.severity = warning # sync call in async method
@@ -132,3 +137,15 @@ dotnet_diagnostic.CA1034.severity = none
generated_code = true
dotnet_analyzer_diagnostic.severity = none
dotnet_diagnostic.IDE0055.severity = none
[*.{g,g.i,generated,designer}.cs]
# Source-generator output. In particular the System.Text.Json generator emits a public
# JsonTypeInfo member per serialisable type, which PublicApiAnalyzers would otherwise demand
# be tracked in PublicAPI.txt — hundreds of entries derived mechanically from the
# [JsonSerializable] list, drowning the entries that describe the actual wire contract.
generated_code = true
dotnet_analyzer_diagnostic.severity = none
dotnet_diagnostic.RS0016.severity = none
dotnet_diagnostic.RS0017.severity = none
dotnet_diagnostic.RS0041.severity = none
dotnet_diagnostic.IDE0055.severity = none