From 935d96c86d740c3a425bf11b8e5ee5dfe26ee613 Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 22 Sep 2026 06:42:06 +0000 Subject: [PATCH 1/2] Forward the curation desk application routes Six signed POSTs beside the roster writes. Apply, mine and withdraw carry no role: an applicant is by definition not on the roster yet. List, decide and window are admin-only upstream, like roster-set, and POSTs for the same reason: every field is private and the GET tier is cached at the edge. The account a decision acts on is `applicant`, never `username`. Build() refuses to copy a client's `username`, so a decision that reused that key would have let an admin decide only on themselves, and the payload test pins the field list. Validation refuses rather than trims, as the roster rules do: a missing or blank answer, an answer past its column, a state outside the three decisions, a role outside trial/curator/mod (admin is never granted by a form), a limit outside 1..200 and a window without a boolean. Answers and the closed-window message are measured in runes, because the columns count characters. Closes #103 --- .../CurationDeskPayloadTests.cs | 137 +++++++++++++++++ .../CurationDeskTestSupport.cs | 9 ++ .../Handlers/PrivateApi.CurationDesk.cs | 144 ++++++++++++++++++ dotnet/EcencyApi/Handlers/Routes.cs | 6 + 4 files changed, 296 insertions(+) diff --git a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs index a3d82815..fff36ac0 100644 --- a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs +++ b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs @@ -17,6 +17,9 @@ public class CurationDeskPayloadTests CurationDeskWrites.MarkClear, CurationDeskWrites.Marks, CurationDeskWrites.Cursor, CurationDeskWrites.RecommendMeta, CurationDeskWrites.RecommendationDismiss, CurationDeskWrites.Ingest, CurationDeskWrites.RosterList, CurationDeskWrites.RosterSet, CurationDeskWrites.RosterRetire, + CurationDeskWrites.ApplicationApply, CurationDeskWrites.ApplicationMine, + CurationDeskWrites.ApplicationWithdraw, CurationDeskWrites.ApplicationList, + CurationDeskWrites.ApplicationDecide, CurationDeskWrites.ApplicationWindow, }; private const string IngestBody = @@ -59,6 +62,12 @@ private static string ValidBodyFor(CurationDeskWrites.Route route) return "{" + forged + "\"curator\":\"bob\",\"role\":\"curator\"}"; if (ReferenceEquals(route, CurationDeskWrites.RosterRetire)) return "{" + forged + "\"curator\":\"bob\"}"; + if (ReferenceEquals(route, CurationDeskWrites.ApplicationApply)) + return "{" + forged + "\"motivation\":\"why\",\"availability\":\"evenings\",\"pick\":\"a post\"}"; + if (ReferenceEquals(route, CurationDeskWrites.ApplicationDecide)) + return "{" + forged + "\"applicant\":\"bob\",\"state\":\"declined\"}"; + if (ReferenceEquals(route, CurationDeskWrites.ApplicationWindow)) + return "{" + forged + "\"open\":true}"; return "{" + forged + "\"limit\":5}"; } @@ -631,6 +640,134 @@ public void ARetireCarriesNothingButTheCurator() Assert.Equal("curator required", Rejected(CurationDeskWrites.RosterRetire, "{\"curator\":\"..\"}")); } + // ---- guest curator applications ------------------------------------------ + + [Fact] + public void AnApplicationForwardsTheThreeAnswersAndNothingElse() + { + var payload = Ok(CurationDeskWrites.ApplicationApply, + "{\"motivation\":\"why\",\"availability\":\"evenings\",\"pick\":\"a post\"," + + "\"discord\":\"someone#1\",\"state\":\"accepted\",\"username\":\"boss\"}"); + Assert.Equal(new[] { "username", "motivation", "availability", "pick" }, + payload.Select(kv => kv.Key).ToArray()); + // The caller is the validated account, never the one the body asked for. + Assert.Equal("alice", payload["username"]!.GetValue()); + } + + [Theory] + [InlineData("{\"availability\":\"evenings\",\"pick\":\"a post\"}", "motivation required")] + [InlineData("{\"motivation\":\" \",\"availability\":\"evenings\",\"pick\":\"a post\"}", "motivation required")] + [InlineData("{\"motivation\":\"why\",\"availability\":null,\"pick\":\"a post\"}", "availability required")] + [InlineData("{\"motivation\":\"why\",\"availability\":5,\"pick\":\"a post\"}", "availability required")] + [InlineData("{\"motivation\":\"why\",\"availability\":\"evenings\"}", "pick required")] + public void AnIncompleteApplicationIsRefused(string json, string expected) + { + Assert.Equal(expected, Rejected(CurationDeskWrites.ApplicationApply, json)); + } + + [Fact] + public void AnAnswerIsMeasuredTheWayTheColumnMeasuresIt() + { + var emoji = string.Concat(Enumerable.Repeat("\U0001F600", 200)); + Assert.Equal(400, emoji.Length); + Assert.True(Ok(CurationDeskWrites.ApplicationApply, + "{\"motivation\":\"why\",\"availability\":\"" + emoji + "\",\"pick\":\"a post\"}") + .ContainsKey("availability")); + Assert.Equal("availability is at most 200 characters", Rejected(CurationDeskWrites.ApplicationApply, + "{\"motivation\":\"why\",\"availability\":\"" + emoji + "\U0001F600\",\"pick\":\"a post\"}")); + } + + [Fact] + public void TheApplicantsOwnRoutesCarryNothingTheCallerSent() + { + foreach (var route in new[] { CurationDeskWrites.ApplicationMine, CurationDeskWrites.ApplicationWithdraw }) + { + var payload = Ok(route, "{\"username\":\"boss\",\"id\":7,\"state\":\"accepted\"}"); + Assert.Equal(new[] { "username" }, payload.Select(kv => kv.Key).ToArray()); + Assert.Equal("alice", payload["username"]!.GetValue()); + } + } + + [Fact] + public void ADecisionNamesTheApplicantInItsOwnField() + { + var payload = Ok(CurationDeskWrites.ApplicationDecide, + "{\"applicant\":\"bob\",\"state\":\"accepted\",\"role\":\"trial\",\"note\":\"guest curator\"}"); + Assert.Equal(new[] { "username", "applicant", "state", "role", "note" }, + payload.Select(kv => kv.Key).ToArray()); + Assert.Equal("alice", payload["username"]!.GetValue()); + Assert.Equal("bob", payload["applicant"]!.GetValue()); + } + + [Theory] + [InlineData("{\"state\":\"accepted\"}", "applicant required")] + [InlineData("{\"applicant\":\"Bob\",\"state\":\"accepted\"}", "applicant required")] + [InlineData("{\"applicant\":\"bob\\n\",\"state\":\"accepted\"}", "applicant required")] + [InlineData("{\"applicant\":\"bob\"}", "invalid state")] + [InlineData("{\"applicant\":\"bob\",\"state\":\"open\"}", "invalid state")] + [InlineData("{\"applicant\":\"bob\",\"state\":\"withdrawn\"}", "invalid state")] + [InlineData("{\"applicant\":\"bob\",\"state\":\"accepted\",\"role\":\"admin\"}", "invalid role")] + [InlineData("{\"applicant\":\"bob\",\"state\":\"accepted\",\"role\":null}", "invalid role")] + public void AMalformedDecisionIsRefusedRatherThanTrimmed(string json, string expected) + { + Assert.Equal(expected, Rejected(CurationDeskWrites.ApplicationDecide, json)); + } + + [Fact] + public void ANeverGrantedRoleCannotArriveThroughAnAcceptance() + { + // The seat an acceptance grants is a trial by default and a curator or mod at most. + // Admin runs the desk, so it is not something a form hands out. + Assert.DoesNotContain("admin", CurationDeskWrites.ApplicationRoles); + } + + [Theory] + [InlineData("{\"state\":\"nonsense\"}", "invalid state")] + [InlineData("{\"limit\":0}", "limit must be a whole number from 1 to 200")] + [InlineData("{\"limit\":201}", "limit must be a whole number from 1 to 200")] + [InlineData("{\"limit\":\"50\"}", "limit must be a whole number from 1 to 200")] + [InlineData("{\"limit\":1.5}", "limit must be a whole number from 1 to 200")] + public void AMalformedQueueRequestIsRefused(string json, string expected) + { + Assert.Equal(expected, Rejected(CurationDeskWrites.ApplicationList, json)); + } + + [Fact] + public void TheQueueTakesAStateAndALimitAndNothingElse() + { + var payload = Ok(CurationDeskWrites.ApplicationList, + "{\"state\":\"open\",\"limit\":10,\"username\":\"boss\",\"cursor\":\"x\"}"); + Assert.Equal(new[] { "username", "state", "limit" }, payload.Select(kv => kv.Key).ToArray()); + // Neither is required: the backend's own defaults answer the common case. + Assert.Equal(new[] { "username" }, + Ok(CurationDeskWrites.ApplicationList, "{}").Select(kv => kv.Key).ToArray()); + } + + [Theory] + [InlineData("{}", "open must be true or false")] + [InlineData("{\"message\":\"back soon\"}", "open must be true or false")] + [InlineData("{\"open\":\"false\"}", "open must be true or false")] + [InlineData("{\"open\":0}", "open must be true or false")] + [InlineData("{\"open\":null}", "open must be true or false")] + public void AWindowWithoutABooleanIsRefused(string json, string expected) + { + Assert.Equal(expected, Rejected(CurationDeskWrites.ApplicationWindow, json)); + } + + [Fact] + public void AClosedWindowMessageIsCappedAtTheColumn() + { + var message = new string('x', 201); + Assert.Equal("invalid message", Rejected(CurationDeskWrites.ApplicationWindow, + "{\"open\":false,\"message\":\"" + message + "\"}")); + var payload = Ok(CurationDeskWrites.ApplicationWindow, + "{\"open\":false,\"message\":\"" + message[..200] + "\"}"); + Assert.Equal(new[] { "username", "open", "message" }, payload.Select(kv => kv.Key).ToArray()); + // A present null clears the message, and the fence lets that through. + Assert.True(Ok(CurationDeskWrites.ApplicationWindow, "{\"open\":true,\"message\":null}") + .ContainsKey("message")); + } + [Fact] public void TheRosterListCarriesNothingTheCallerSent() { diff --git a/dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs b/dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs index 156c9135..cc1aab61 100644 --- a/dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs +++ b/dotnet/EcencyApi.Tests/CurationDeskTestSupport.cs @@ -262,5 +262,14 @@ public static TestClock UseTestClock() yield return ("roster-set", PrivateApi.CurationDeskRosterSet, "{" + code + ",\"curator\":\"bob\",\"role\":\"curator\"}"); yield return ("roster-retire", PrivateApi.CurationDeskRosterRetire, "{" + code + ",\"curator\":\"bob\"}"); + yield return ("application-apply", PrivateApi.CurationDeskApplicationApply, + "{" + code + ",\"motivation\":\"why\",\"availability\":\"evenings\",\"pick\":\"a post\"}"); + yield return ("application-mine", PrivateApi.CurationDeskApplicationMine, "{" + code + "}"); + yield return ("application-withdraw", PrivateApi.CurationDeskApplicationWithdraw, "{" + code + "}"); + yield return ("application-list", PrivateApi.CurationDeskApplicationList, "{" + code + ",\"state\":\"open\"}"); + yield return ("application-decide", PrivateApi.CurationDeskApplicationDecide, + "{" + code + ",\"applicant\":\"bob\",\"state\":\"accepted\",\"role\":\"trial\"}"); + yield return ("application-window", PrivateApi.CurationDeskApplicationWindow, + "{" + code + ",\"open\":false,\"message\":\"back soon\"}"); } } diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs index 800e7e24..bece8f8a 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs @@ -454,6 +454,33 @@ public static Task CurationDeskRecommendMeta(HttpContext ctx) => public static Task CurationDeskIngest(HttpContext ctx) => ServeDeskWrite(ctx, CurationDeskWrites.Ingest); + // POST /private-api/curation-desk/application-apply + // Guest curator applications. Apply, mine and withdraw carry no role: the + // applicant is by definition not on the roster yet, and the backend decides + // what each account may do with its own application. + public static Task CurationDeskApplicationApply(HttpContext ctx) => + ServeDeskWrite(ctx, CurationDeskWrites.ApplicationApply); + + // POST /private-api/curation-desk/application-mine + public static Task CurationDeskApplicationMine(HttpContext ctx) => + ServeDeskWrite(ctx, CurationDeskWrites.ApplicationMine); + + // POST /private-api/curation-desk/application-withdraw + public static Task CurationDeskApplicationWithdraw(HttpContext ctx) => + ServeDeskWrite(ctx, CurationDeskWrites.ApplicationWithdraw); + + // POST /private-api/curation-desk/application-list + public static Task CurationDeskApplicationList(HttpContext ctx) => + ServeDeskWrite(ctx, CurationDeskWrites.ApplicationList); + + // POST /private-api/curation-desk/application-decide + public static Task CurationDeskApplicationDecide(HttpContext ctx) => + ServeDeskWrite(ctx, CurationDeskWrites.ApplicationDecide); + + // POST /private-api/curation-desk/application-window + public static Task CurationDeskApplicationWindow(HttpContext ctx) => + ServeDeskWrite(ctx, CurationDeskWrites.ApplicationWindow); + // POST /private-api/curation-desk/recommendation-dismiss public static Task CurationDeskRecommendationDismiss(HttpContext ctx) => ServeDeskWrite(ctx, CurationDeskWrites.RecommendationDismiss); @@ -773,6 +800,28 @@ public sealed record Route(string UpstreamPath, string[] Keys, bool ForwardClien /// The backend keeps the event id in a varchar(200). public const int MaxIngestIdLength = 200; + /// + /// Guest curator applications (spec 6.6). The three questions the apply form asks, each + /// with the length the backend caps it at. Counted in RUNES, not UTF-16 units, for the + /// reason the curator note is: the column counts characters and so does Python's len(). + /// + public static readonly (string Key, int Max)[] ApplicationAnswers = + { + ("motivation", 500), ("availability", 200), ("pick", 500), + }; + public static readonly IReadOnlySet ApplicationStates = + new HashSet { "open", "shortlisted", "accepted", "declined", "withdrawn" }; + /// What an admin may set. `open` is not a decision and `withdrawn` is the applicant's. + public static readonly IReadOnlySet ApplicationDecisions = + new HashSet { "shortlisted", "accepted", "declined" }; + /// Roles an acceptance may grant. `admin` is deliberately absent: desk keys are + /// not handed out by a form, and the backend refuses it too. + public static readonly IReadOnlySet ApplicationRoles = + new HashSet { "trial", "curator", "mod" }; + public const int MaxApplicationNoteLength = 500; + public const int MaxApplicationMessageLength = 200; + public const int MaxApplicationListLimit = 200; + /// /// Views the roster feed takes: the public ones plus `excluded`, which is /// the only place an excluded row is ever listed. @@ -848,6 +897,34 @@ public sealed record Route(string UpstreamPath, string[] Keys, bool ForwardClien /// public static readonly Route Ingest = new("curation/desk/ingest", new[] { "v", "type", "id", "ts", "payload" }); + /// + /// Guest curator applications (spec 6.6). Apply, mine and withdraw are open to any + /// signed-in account; list, decide and window are admin-only upstream, like the roster + /// writes above, and for the same reason they are POSTs: the fields are private. + /// + public static readonly Route ApplicationApply = new("curation/desk/applications/apply", + new[] { "motivation", "availability", "pick" }); + + public static readonly Route ApplicationMine = new("curation/desk/applications/mine", + Array.Empty()); + + public static readonly Route ApplicationWithdraw = new("curation/desk/applications/withdraw", + Array.Empty()); + + public static readonly Route ApplicationList = new("curation/desk/applications/list", + new[] { "state", "limit" }); + + /// + /// The account being decided on is `applicant`. It can never be `username`: that key + /// carries the validated caller and Build() refuses to copy a client's version of it, + /// so an admin reusing it would only ever decide on themselves. + /// + public static readonly Route ApplicationDecide = new("curation/desk/applications/decide", + new[] { "applicant", "state", "role", "note" }); + + public static readonly Route ApplicationWindow = new("curation/desk/applications/window", + new[] { "open", "message" }); + /// /// The upstream body: the validated username plus the route's whitelisted /// keys copied as the client sent them. `username` and `code` are never in @@ -1085,6 +1162,73 @@ private static void Truncate(JsonObject payload, string key, int max) { return RequireAuthorPermlink(body) ?? RequireOneOf(body, "action", DismissActions); } + if (ReferenceEquals(route, ApplicationApply)) + { + foreach (var (key, max) in ApplicationAnswers) + { + if (body.Str(key) is not { } answer || answer.AsSpan().Trim().Length == 0) + { + return $"{key} required"; + } + if (answer.EnumerateRunes().Count() > max) + { + return $"{key} is at most {max} characters"; + } + } + return null; + } + if (ReferenceEquals(route, ApplicationList)) + { + if (body.ContainsKey("state")) + { + var stateError = RequireOneOf(body, "state", ApplicationStates); + if (stateError != null) return stateError; + } + if (body.TryGetPropertyValue("limit", out var limit) && limit is not null) + { + if (limit is not JsonValue take || take.GetValueKind() is not JsonValueKind.Number + || !take.TryGetValue(out var rows) || rows < 1 || rows > MaxApplicationListLimit) + { + return $"limit must be a whole number from 1 to {MaxApplicationListLimit}"; + } + } + return null; + } + if (ReferenceEquals(route, ApplicationDecide)) + { + if (body.Str("applicant") is not { } applicant || !HiveNames.IsAccountName(applicant)) + { + return "applicant required"; + } + var decision = RequireOneOf(body, "state", ApplicationDecisions); + if (decision != null) return decision; + if (body.ContainsKey("role")) + { + var roleError = RequireOneOf(body, "role", ApplicationRoles); + if (roleError != null) return roleError; + } + if (body.TryGetPropertyValue("note", out var decisionNote) && decisionNote is not null + && (body.Str("note") is not { } noteText + || noteText.EnumerateRunes().Count() > MaxApplicationNoteLength)) + { + return "invalid note"; + } + return null; + } + if (ReferenceEquals(route, ApplicationWindow)) + { + if (body.Field("open")?.GetValueKind() is not (JsonValueKind.True or JsonValueKind.False)) + { + return "open must be true or false"; + } + if (body.TryGetPropertyValue("message", out var message) && message is not null + && (body.Str("message") is not { } messageText + || messageText.EnumerateRunes().Count() > MaxApplicationMessageLength)) + { + return "invalid message"; + } + return null; + } if (ReferenceEquals(route, RosterSet)) { if (body.Str("curator") is not { } curator || !HiveNames.IsAccountName(curator)) diff --git a/dotnet/EcencyApi/Handlers/Routes.cs b/dotnet/EcencyApi/Handlers/Routes.cs index 46522d9f..2f98a532 100644 --- a/dotnet/EcencyApi/Handlers/Routes.cs +++ b/dotnet/EcencyApi/Handlers/Routes.cs @@ -194,6 +194,12 @@ public static void Map(WebApplication app) app.MapPost("/private-api/curation-desk/recommend-meta", PrivateApi.CurationDeskRecommendMeta); app.MapPost("/private-api/curation-desk/recommendation-dismiss", PrivateApi.CurationDeskRecommendationDismiss); app.MapPost("/private-api/curation-desk/ingest", PrivateApi.CurationDeskIngest); + app.MapPost("/private-api/curation-desk/application-apply", PrivateApi.CurationDeskApplicationApply); + app.MapPost("/private-api/curation-desk/application-mine", PrivateApi.CurationDeskApplicationMine); + app.MapPost("/private-api/curation-desk/application-withdraw", PrivateApi.CurationDeskApplicationWithdraw); + app.MapPost("/private-api/curation-desk/application-list", PrivateApi.CurationDeskApplicationList); + app.MapPost("/private-api/curation-desk/application-decide", PrivateApi.CurationDeskApplicationDecide); + app.MapPost("/private-api/curation-desk/application-window", PrivateApi.CurationDeskApplicationWindow); // ---- SSR RPC cache (internal, header-gated; see SsrRpc.cs) ---- app.MapPost("/private-api/ssr/rpc", SsrRpc.Rpc); From d793ed942f355da4a3fc42bad680d8f099d3d42d Mon Sep 17 00:00:00 2001 From: feruzm Date: Tue, 22 Sep 2026 07:04:28 +0000 Subject: [PATCH 2/2] Refuse an unknown application field and a null queue limit Two review findings, both the same shape as the roster's `rules` contract: a value this service claims to check must be refused, not quietly dropped. An unknown key on an application was allowlisted away, so the desk never saw the field it would have answered 400 for and the applicant was told their answers were sent, minus one. A present `limit: null` skipped the range check entirely because the check ran only for a non-null value, while CopyIfPresent forwards a present null upstream. The six routes also join the parity driver's curation-desk list, which is what records them as deliberate additions to the reference build. --- .../CurationDeskPayloadTests.cs | 24 ++++++++++++++++++- .../Handlers/PrivateApi.CurationDesk.cs | 17 ++++++++++++- dotnet/parity/driver.py | 5 +++- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs index fff36ac0..5a676c9e 100644 --- a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs +++ b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs @@ -647,13 +647,35 @@ public void AnApplicationForwardsTheThreeAnswersAndNothingElse() { var payload = Ok(CurationDeskWrites.ApplicationApply, "{\"motivation\":\"why\",\"availability\":\"evenings\",\"pick\":\"a post\"," - + "\"discord\":\"someone#1\",\"state\":\"accepted\",\"username\":\"boss\"}"); + + "\"code\":\"as:alice\",\"username\":\"boss\"}"); Assert.Equal(new[] { "username", "motivation", "availability", "pick" }, payload.Select(kv => kv.Key).ToArray()); // The caller is the validated account, never the one the body asked for. Assert.Equal("alice", payload["username"]!.GetValue()); } + [Theory] + [InlineData("discord")] + [InlineData("state")] + [InlineData("role")] + public void AnUnknownApplicationFieldIsRefusedRatherThanDropped(string field) + { + // The desk answers 400 for a field it does not know, so dropping one here would + // turn that refusal into a silent half-application. + Assert.Equal($"unknown field: {field}", Rejected(CurationDeskWrites.ApplicationApply, + "{\"motivation\":\"why\",\"availability\":\"evenings\",\"pick\":\"a post\"," + + $"\"{field}\":\"x\"}}")); + } + + [Fact] + public void APresentButNullQueueLimitIsRefusedRatherThanForwarded() + { + Assert.Equal("limit must be a whole number from 1 to 200", + Rejected(CurationDeskWrites.ApplicationList, "{\"limit\":null}")); + // absent is still absent: that is how the backend's own default answers + Assert.False(Ok(CurationDeskWrites.ApplicationList, "{}").ContainsKey("limit")); + } + [Theory] [InlineData("{\"availability\":\"evenings\",\"pick\":\"a post\"}", "motivation required")] [InlineData("{\"motivation\":\" \",\"availability\":\"evenings\",\"pick\":\"a post\"}", "motivation required")] diff --git a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs index bece8f8a..4a3f1c4d 100644 --- a/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs +++ b/dotnet/EcencyApi/Handlers/PrivateApi.CurationDesk.cs @@ -1164,6 +1164,17 @@ private static void Truncate(JsonObject payload, string key, int max) } if (ReferenceEquals(route, ApplicationApply)) { + // The desk refuses an answer key it does not know rather than storing the + // rest, so dropping one here would turn its 400 into a silent half-application: + // the applicant would be told their answers were sent, minus one. + foreach (var field in body) + { + if (field.Key is "code" or "username") continue; + if (Array.FindIndex(ApplicationAnswers, answer => answer.Key == field.Key) < 0) + { + return $"unknown field: {field.Key}"; + } + } foreach (var (key, max) in ApplicationAnswers) { if (body.Str(key) is not { } answer || answer.AsSpan().Trim().Length == 0) @@ -1184,7 +1195,11 @@ private static void Truncate(JsonObject payload, string key, int max) var stateError = RequireOneOf(body, "state", ApplicationStates); if (stateError != null) return stateError; } - if (body.TryGetPropertyValue("limit", out var limit) && limit is not null) + // A PRESENT limit is checked, null included: CopyIfPresent forwards a null + // through the allowlist, so `"limit": null` would travel upstream while the + // fence claimed every limit is a whole number in range (the same hole the + // roster's `rules` had). + if (body.TryGetPropertyValue("limit", out var limit)) { if (limit is not JsonValue take || take.GetValueKind() is not JsonValueKind.Number || !take.TryGetValue(out var rows) || rows < 1 || rows > MaxApplicationListLimit) diff --git a/dotnet/parity/driver.py b/dotnet/parity/driver.py index 81846b5d..4faea61e 100644 --- a/dotnet/parity/driver.py +++ b/dotnet/parity/driver.py @@ -260,7 +260,10 @@ def norm_body(text): ] + [ f"/private-api/curation-desk/{route}::{case}" for route in ("roster-feed", "tick", "mark", "mark-clear", "marks", "cursor", - "recommend-meta", "recommendation-dismiss", "ingest") + "recommend-meta", "recommendation-dismiss", "ingest", + # Guest curator applications, added with the desk's apply page. + "application-apply", "application-mine", "application-withdraw", + "application-list", "application-decide", "application-window") for case in ("min", "pop", "badcode") ]