@@ -1225,12 +1225,15 @@ public static List<string> FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
12251225 {
12261226 var fullPath = string . IsNullOrEmpty ( path ) ? kvp . Key : $ "{ path } :{ kvp . Key } ";
12271227
1228- // Auth:Schemes is a name-keyed open dictionary, but the keys *inside* each named scheme
1229- // are not arbitrary — they depend on the scheme's Type. Pick a type-discriminated schema
1230- // so any name (including the docs-example names short_session/api_token/admin_jwt) gets
1231- // the same per-type validation. Without this, a scheme name that happens to match an
1232- // example in defaults would be validated against that example's incomplete key set,
1233- // flagging perfectly valid per-type override keys (Bug A in CONFIG_VALIDATION_NAMED_SCHEMES_BUG.md).
1228+ // Name-keyed open dictionaries: the named children are arbitrary (user-chosen) but each
1229+ // child has a well-defined inner shape. Pick that shape and validate against it. Without
1230+ // these intercepts, the validator falls back to looking up the user-chosen name in
1231+ // defaults and either flags it as unknown (if the name has no example match) or matches
1232+ // it against an example's partial key set (if the name *does* collide with an example,
1233+ // which falsely rejects valid per-type override keys).
1234+ //
1235+ // Each intercept short-circuits the rest of the loop body so neither the example-lookup
1236+ // path nor the IsOpenDictionarySection fallback fires.
12341237 if ( string . Equals ( path , "Auth:Schemes" , StringComparison . Ordinal ) && kvp . Value is JsonObject schemeObj )
12351238 {
12361239 var schemeSchema = GetAuthSchemeSchemaByType ( schemeObj ) ;
@@ -1242,6 +1245,26 @@ public static List<string> FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
12421245 // startup for missing/invalid Type; no point double-reporting it here.
12431246 continue ;
12441247 }
1248+ if ( string . Equals ( path , "RateLimiterOptions:Policies" , StringComparison . Ordinal ) && kvp . Value is JsonObject policyObj )
1249+ {
1250+ var policySchema = GetRateLimiterPolicySchemaByType ( policyObj ) ;
1251+ if ( policySchema is not null )
1252+ {
1253+ warnings . AddRange ( FindUnknownConfigKeys ( policySchema , policyObj , fullPath ) ) ;
1254+ }
1255+ // BuildRateLimiter silently skips policies with no/invalid Type — match that here.
1256+ continue ;
1257+ }
1258+ if ( string . Equals ( path , "CacheOptions:Profiles" , StringComparison . Ordinal ) && kvp . Value is JsonObject profileObj )
1259+ {
1260+ warnings . AddRange ( FindUnknownConfigKeys ( GetCacheProfileSchema ( ) , profileObj , fullPath ) ) ;
1261+ continue ;
1262+ }
1263+ if ( string . Equals ( path , "ValidationOptions:Rules" , StringComparison . Ordinal ) && kvp . Value is JsonObject ruleObj )
1264+ {
1265+ warnings . AddRange ( FindUnknownConfigKeys ( GetValidationRuleSchema ( ) , ruleObj , fullPath ) ) ;
1266+ continue ;
1267+ }
12451268
12461269 if ( ContainsKeyIgnoreCase ( defaultObj , kvp . Key , out var matchedKey ) )
12471270 {
@@ -1341,6 +1364,134 @@ public static List<string> FindUnknownConfigKeys(JsonNode? defaults, JsonNode? a
13411364 return null ;
13421365 }
13431366
1367+ private static string ? ReadStringField ( JsonObject obj , string key )
1368+ {
1369+ foreach ( var kvp in obj )
1370+ {
1371+ if ( string . Equals ( kvp . Key , key , StringComparison . OrdinalIgnoreCase ) )
1372+ {
1373+ if ( kvp . Value is JsonValue val && val . TryGetValue < string > ( out var s ) )
1374+ {
1375+ return s ;
1376+ }
1377+ return null ;
1378+ }
1379+ }
1380+ return null ;
1381+ }
1382+
1383+ private static JsonObject PartitionSchema ( ) => new ( )
1384+ {
1385+ [ "Sources" ] = new JsonArray ( new JsonObject
1386+ {
1387+ [ "Type" ] = null ,
1388+ [ "Name" ] = null ,
1389+ [ "Value" ] = null
1390+ } ) ,
1391+ [ "BypassAuthenticated" ] = false
1392+ } ;
1393+
1394+ /// <summary>
1395+ /// Returns the per-type schema for a named entry under <c>RateLimiterOptions:Policies</c>, keyed
1396+ /// off the policy's <c>Type</c> field (FixedWindow / SlidingWindow / TokenBucket / Concurrency).
1397+ /// Returns null when Type is missing/invalid — BuildRateLimiter silently skips such policies, so
1398+ /// the validator does the same to avoid double-reporting.
1399+ /// </summary>
1400+ private static JsonObject ? GetRateLimiterPolicySchemaByType ( JsonObject policy )
1401+ {
1402+ var type = ReadStringField ( policy , "Type" ) ;
1403+ if ( string . IsNullOrWhiteSpace ( type ) )
1404+ {
1405+ return null ;
1406+ }
1407+ if ( string . Equals ( type , "FixedWindow" , StringComparison . OrdinalIgnoreCase ) )
1408+ {
1409+ return new JsonObject
1410+ {
1411+ [ "Type" ] = "FixedWindow" ,
1412+ [ "Enabled" ] = true ,
1413+ [ "PermitLimit" ] = 100 ,
1414+ [ "WindowSeconds" ] = 60 ,
1415+ [ "QueueLimit" ] = 10 ,
1416+ [ "AutoReplenishment" ] = true ,
1417+ [ "Partition" ] = PartitionSchema ( )
1418+ } ;
1419+ }
1420+ if ( string . Equals ( type , "SlidingWindow" , StringComparison . OrdinalIgnoreCase ) )
1421+ {
1422+ return new JsonObject
1423+ {
1424+ [ "Type" ] = "SlidingWindow" ,
1425+ [ "Enabled" ] = true ,
1426+ [ "PermitLimit" ] = 100 ,
1427+ [ "WindowSeconds" ] = 60 ,
1428+ [ "SegmentsPerWindow" ] = 6 ,
1429+ [ "QueueLimit" ] = 10 ,
1430+ [ "AutoReplenishment" ] = true ,
1431+ [ "Partition" ] = PartitionSchema ( )
1432+ } ;
1433+ }
1434+ if ( string . Equals ( type , "TokenBucket" , StringComparison . OrdinalIgnoreCase ) )
1435+ {
1436+ return new JsonObject
1437+ {
1438+ [ "Type" ] = "TokenBucket" ,
1439+ [ "Enabled" ] = true ,
1440+ [ "TokenLimit" ] = 100 ,
1441+ [ "TokensPerPeriod" ] = 10 ,
1442+ [ "ReplenishmentPeriodSeconds" ] = 10 ,
1443+ [ "QueueLimit" ] = 10 ,
1444+ [ "AutoReplenishment" ] = true ,
1445+ [ "Partition" ] = PartitionSchema ( )
1446+ } ;
1447+ }
1448+ if ( string . Equals ( type , "Concurrency" , StringComparison . OrdinalIgnoreCase ) )
1449+ {
1450+ return new JsonObject
1451+ {
1452+ [ "Type" ] = "Concurrency" ,
1453+ [ "Enabled" ] = true ,
1454+ [ "PermitLimit" ] = 10 ,
1455+ [ "QueueLimit" ] = 5 ,
1456+ [ "OldestFirst" ] = true ,
1457+ [ "Partition" ] = PartitionSchema ( )
1458+ } ;
1459+ }
1460+ return null ;
1461+ }
1462+
1463+ /// <summary>
1464+ /// Returns the schema for any entry under <c>CacheOptions:Profiles</c>. All profiles share the same
1465+ /// key set regardless of <c>Type</c> (Memory / Redis / Hybrid); only the backend selection varies.
1466+ /// </summary>
1467+ private static JsonObject GetCacheProfileSchema ( ) => new ( )
1468+ {
1469+ [ "Enabled" ] = false ,
1470+ [ "Type" ] = null ,
1471+ [ "Expiration" ] = null ,
1472+ [ "Parameters" ] = new JsonArray ( ) ,
1473+ [ "When" ] = new JsonArray ( new JsonObject
1474+ {
1475+ [ "Parameter" ] = null ,
1476+ [ "Value" ] = null ,
1477+ [ "Then" ] = null
1478+ } )
1479+ } ;
1480+
1481+ /// <summary>
1482+ /// Returns the schema for any entry under <c>ValidationOptions:Rules</c>. All rules share the same
1483+ /// key set regardless of <c>Type</c> (NotNull / NotEmpty / Required / Regex / MinLength / MaxLength).
1484+ /// </summary>
1485+ private static JsonObject GetValidationRuleSchema ( ) => new ( )
1486+ {
1487+ [ "Type" ] = null ,
1488+ [ "Pattern" ] = null ,
1489+ [ "MinLength" ] = null ,
1490+ [ "MaxLength" ] = null ,
1491+ [ "Message" ] = null ,
1492+ [ "StatusCode" ] = 400
1493+ } ;
1494+
13441495 private static bool ContainsKeyIgnoreCase ( JsonObject obj , string key , out string ? matchedKey )
13451496 {
13461497 foreach ( var kvp in obj )
@@ -1360,13 +1511,16 @@ private static bool ContainsKeyIgnoreCase(JsonObject obj, string key, out string
13601511 /// </summary>
13611512 private static bool IsOpenDictionarySection ( string path )
13621513 {
1514+ // NOTE: Auth:Schemes, RateLimiterOptions:Policies, CacheOptions:Profiles, and
1515+ // ValidationOptions:Rules are intentionally NOT in this list. They are name-keyed open
1516+ // dictionaries too, but each is intercepted earlier in FindUnknownConfigKeys with a
1517+ // type/shape-discriminated schema so user-chosen names get the same per-entry validation that
1518+ // matching example names get. Adding them here would silently disable that.
13631519 if ( path is
13641520 "ConnectionStrings" or
13651521 "Log:MinimalLevels" or
13661522 "Log:OTLResourceAttributes" or
13671523 "CommandRetryOptions:Strategies" or
1368- "ValidationOptions:Rules" or
1369- "Auth:Schemes" or
13701524 "NpgsqlRest:AuthenticationOptions:ContextKeyClaimsMapping" or
13711525 "NpgsqlRest:AuthenticationOptions:ParameterNameClaimsMapping" or
13721526 "NpgsqlRest:ClientCodeGen:CustomHeaders" )
0 commit comments