diff --git a/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs b/dotnet/EcencyApi.Tests/CurationDeskPayloadTests.cs index a3d82815..5a676c9e 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,156 @@ 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\"," + + "\"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")] + [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..4a3f1c4d 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,88 @@ private static void Truncate(JsonObject payload, string key, int max) { return RequireAuthorPermlink(body) ?? RequireOneOf(body, "action", DismissActions); } + 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) + { + 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; + } + // 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) + { + 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); 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") ]