From 007729d45317ee0579c67385a7cd23be5344c6e4 Mon Sep 17 00:00:00 2001 From: "Aaron K. Clark" Date: Wed, 29 Jul 2026 10:47:50 -0500 Subject: [PATCH] fix(api): bound JSON and persisted input sizes --- BACKLOG.md | 2 +- README.md | 4 + api/ApplyTrack.Api.Tests/InputLimitTests.cs | 223 ++++++++++++++++++ api/ApplyTrack.Api/ApplyTrack.Api.csproj | 2 +- api/ApplyTrack.Api/Data/AppFields.cs | 4 +- api/ApplyTrack.Api/Data/ApplicationRepo.cs | 7 +- api/ApplyTrack.Api/Data/Criteria.cs | 1 + api/ApplyTrack.Api/Data/InputLimits.cs | 164 +++++++++++++ api/ApplyTrack.Api/Data/Resume.cs | 1 + .../Endpoints/BlacklistEndpoints.cs | 1 + .../Endpoints/MaterialsEndpoints.cs | 7 + .../Middleware/JsonBodyLimitMiddleware.cs | 62 +++++ api/ApplyTrack.Api/Program.cs | 4 + pyproject.toml | 2 +- uv.lock | 2 +- 15 files changed, 479 insertions(+), 7 deletions(-) create mode 100644 api/ApplyTrack.Api.Tests/InputLimitTests.cs create mode 100644 api/ApplyTrack.Api/Data/InputLimits.cs create mode 100644 api/ApplyTrack.Api/Middleware/JsonBodyLimitMiddleware.cs diff --git a/BACKLOG.md b/BACKLOG.md index 242d409..cd44842 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -9,7 +9,7 @@ or `SPRINTS.md` are not committed backlog until they have a corresponding issue. - [x] [#51 — Close the Python poller link-check DNS rebinding gap](https://github.com/CryptoJones/OSApplyTrack/issues/51) - [x] [#49 — Restrict forwarded-header trust to configured proxies](https://github.com/CryptoJones/OSApplyTrack/issues/49) -- [ ] [#50 — Add global JSON body caps and per-field/cardinality limits](https://github.com/CryptoJones/OSApplyTrack/issues/50) +- [x] [#50 — Add global JSON body caps and per-field/cardinality limits](https://github.com/CryptoJones/OSApplyTrack/issues/50) - [ ] [#52 — Serialize overlapping tenant poll runs](https://github.com/CryptoJones/OSApplyTrack/issues/52) ## Operations and scalability diff --git a/README.md b/README.md index e572caf..a8bfaf8 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,10 @@ OSApplyTrack is built to face the public internet behind a reverse proxy: than storing anything in the clear. - **Rate limiting.** The magic-link and poll endpoints are per-IP fixed-window rate-limited so the always-200 auth surface can't be abused for spam or probing. +- **Bounded API input.** Ordinary JSON mutations are capped at 1 MiB, application + strings/notes have explicit length ceilings, and criteria/résumé collections + reject excessive cardinality with a clear `400` instead of growing rows or LLM + prompts without limit. - **SSRF-hardened link probing.** The link prober refuses to connect to private/loopback/link-local/reserved addresses and re-checks every redirect hop, so a hostile listing URL can't pivot into your network. diff --git a/api/ApplyTrack.Api.Tests/InputLimitTests.cs b/api/ApplyTrack.Api.Tests/InputLimitTests.cs new file mode 100644 index 0000000..0caa900 --- /dev/null +++ b/api/ApplyTrack.Api.Tests/InputLimitTests.cs @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +using System.Net; +using System.Text; +using System.Text.Json; +using ApplyTrack.Api.Auth; +using ApplyTrack.Api.Data; +using ApplyTrack.Api.Middleware; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; + +namespace ApplyTrack.Api.Tests; + +/// Boundary and over-limit coverage for ordinary authenticated API input. +[Collection(PostgresCollection.Name)] +public class InputLimitTests : IAsyncLifetime +{ + private readonly PostgresFixture _pg; + private WebApplicationFactory _factory = null!; + private HttpClient _client = null!; + + public InputLimitTests(PostgresFixture pg) => _pg = pg; + + public async Task InitializeAsync() + { + _factory = new WebApplicationFactory().WithWebHostBuilder(b => + b.UseSetting("ConnectionStrings:Postgres", _pg.ConnectionString)); + var (_, sid) = await TestAuth.SeedSessionAsync(_pg.ConnectionString); + _client = _factory.CreateClient(); + _client.DefaultRequestHeaders.Add("Cookie", $"{AuthCookie.Name}={sid}"); + } + + public async Task DisposeAsync() + { + _client.Dispose(); + await _factory.DisposeAsync(); + } + + private static StringContent Json(string body) => new(body, Encoding.UTF8, "application/json"); + + private static StringContent Json(object body) => + Json(JsonSerializer.Serialize(body)); + + private static async Task Detail(HttpResponseMessage response) + { + using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync()); + return document.RootElement.GetProperty("detail").GetString() ?? ""; + } + + private static StringContent SizedJson(int bytes) + { + const string prefix = "{\"padding\":\""; + const string suffix = "\"}"; + var body = prefix + new string('x', bytes - prefix.Length - suffix.Length) + suffix; + Assert.Equal(bytes, Encoding.UTF8.GetByteCount(body)); + return Json(body); + } + + [Fact] + public async Task Json_body_accepts_the_exact_global_limit() + { + var response = await _client.PostAsync( + "/api/poll", SizedJson((int)JsonBodyLimitMiddleware.MaxBytes)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task Json_body_rejects_one_byte_over_with_detail() + { + var response = await _client.PostAsync( + "/api/poll", SizedJson((int)JsonBodyLimitMiddleware.MaxBytes + 1)); + + Assert.Equal(HttpStatusCode.RequestEntityTooLarge, response.StatusCode); + Assert.Contains("1024 KiB", await Detail(response)); + } + + [Fact] + public async Task Application_accepts_field_and_notes_boundaries() + { + var response = await _client.PostAsync("/api/apps", Json(new + { + company = new string('c', InputLimits.Company), + role = "Engineer", + notes = new string('n', InputLimits.Notes), + })); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + } + + [Theory] + [InlineData("company")] + [InlineData("notes")] + public async Task Application_rejects_overlong_fields_with_detail(string field) + { + var payload = field == "company" + ? new { company = new string('c', InputLimits.Company + 1), notes = "" } + : new { company = "Acme", notes = new string('n', InputLimits.Notes + 1) }; + + var response = await _client.PostAsync("/api/apps", Json(payload)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains(field, await Detail(response)); + } + + [Fact] + public async Task Criteria_accepts_collection_boundaries() + { + var response = await _client.PutAsync("/api/criteria", Json(new + { + keywords = Enumerable.Range(0, InputLimits.Keywords).Select(i => $"keyword-{i}"), + exclude_locations = Enumerable.Range(0, InputLimits.ExcludedLocations) + .Select(i => $"location-{i}"), + ats_boards = Enumerable.Range(0, InputLimits.AtsBoards) + .Select(i => new { provider = "greenhouse", slug = $"company-{i}" }), + })); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData("keywords")] + [InlineData("exclude_locations")] + [InlineData("ats_boards")] + public async Task Criteria_rejects_over_cardinality_with_detail(string field) + { + object payload = field switch + { + "keywords" => new + { + keywords = Enumerable.Range(0, InputLimits.Keywords + 1) + .Select(i => $"keyword-{i}"), + }, + "exclude_locations" => new + { + exclude_locations = Enumerable.Range(0, InputLimits.ExcludedLocations + 1) + .Select(i => $"location-{i}"), + }, + _ => new + { + ats_boards = Enumerable.Range(0, InputLimits.AtsBoards + 1) + .Select(i => new { provider = "lever", slug = $"company-{i}" }), + }, + }; + + var response = await _client.PutAsync("/api/criteria", Json(payload)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains(field, await Detail(response)); + } + + [Fact] + public async Task Resume_accepts_collection_boundaries() + { + var response = await _client.PutAsync("/api/resume", Json(new + { + experience = Enumerable.Range(0, InputLimits.ResumeExperience).Select(i => new + { + company = $"Company {i}", + title = "Engineer", + highlights = Enumerable.Range(0, InputLimits.ResumeHighlights) + .Select(h => $"Highlight {h}"), + }), + skills = Enumerable.Range(0, InputLimits.ResumeSkills).Select(i => $"Skill {i}"), + certifications = Enumerable.Range(0, InputLimits.ResumeCertifications) + .Select(i => $"Certification {i}"), + links = Enumerable.Range(0, InputLimits.ResumeLinks) + .Select(i => new { label = $"Link {i}", url = $"https://example.com/{i}" }), + })); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Theory] + [InlineData("experience")] + [InlineData("highlights")] + [InlineData("skills")] + [InlineData("certifications")] + [InlineData("links")] + public async Task Resume_rejects_over_cardinality_with_detail(string field) + { + object payload = field switch + { + "experience" => new + { + experience = Enumerable.Range(0, InputLimits.ResumeExperience + 1) + .Select(i => new { company = $"Company {i}" }), + }, + "highlights" => new + { + experience = new[] + { + new + { + company = "Acme", + highlights = Enumerable.Range(0, InputLimits.ResumeHighlights + 1) + .Select(i => $"Highlight {i}"), + }, + }, + }, + "skills" => new + { + skills = Enumerable.Range(0, InputLimits.ResumeSkills + 1) + .Select(i => $"Skill {i}"), + }, + "certifications" => new + { + certifications = Enumerable.Range(0, InputLimits.ResumeCertifications + 1) + .Select(i => $"Certification {i}"), + }, + _ => new + { + links = Enumerable.Range(0, InputLimits.ResumeLinks + 1) + .Select(i => new { url = $"https://example.com/{i}" }), + }, + }; + + var response = await _client.PutAsync("/api/resume", Json(payload)); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Contains(field, await Detail(response)); + } +} diff --git a/api/ApplyTrack.Api/ApplyTrack.Api.csproj b/api/ApplyTrack.Api/ApplyTrack.Api.csproj index 2c7c15a..36c6df5 100644 --- a/api/ApplyTrack.Api/ApplyTrack.Api.csproj +++ b/api/ApplyTrack.Api/ApplyTrack.Api.csproj @@ -5,7 +5,7 @@ enable enable ApplyTrack.Api - 1.11.3 + 1.11.4 Aaron K. Clark Copyright 2026 Aaron K. Clark Apache-2.0 diff --git a/api/ApplyTrack.Api/Data/AppFields.cs b/api/ApplyTrack.Api/Data/AppFields.cs index 385ba61..c2c8465 100644 --- a/api/ApplyTrack.Api/Data/AppFields.cs +++ b/api/ApplyTrack.Api/Data/AppFields.cs @@ -39,7 +39,7 @@ public AppFields Normalized() static string S(string? v) => (v ?? "").Trim(); var lane = S(Lane).ToLowerInvariant(); var status = S(Status).ToLowerInvariant(); - return this with + var normalized = this with { Company = S(Company), Role = S(Role), @@ -57,5 +57,7 @@ public AppFields Normalized() Score = S(Score), Notes = (Notes ?? "").TrimEnd(), }; + InputLimits.ValidateApplication(normalized); + return normalized; } } diff --git a/api/ApplyTrack.Api/Data/ApplicationRepo.cs b/api/ApplyTrack.Api/Data/ApplicationRepo.cs index 1b83ee7..0fc0cac 100644 --- a/api/ApplyTrack.Api/Data/ApplicationRepo.cs +++ b/api/ApplyTrack.Api/Data/ApplicationRepo.cs @@ -233,8 +233,11 @@ ON CONFLICT (tenant_id, name) DO NOTHING public Task UpdateStructuredAsync(string name, AppFields fields, string? expectedVersion) => DoUpdateAsync(name, fields.Normalized(), expectedVersion); - public Task UpdateRawAsync(string name, string content, string? expectedVersion) => - DoUpdateAsync(name, MarkdownCodec.Parse(content), expectedVersion); + public Task UpdateRawAsync(string name, string content, string? expectedVersion) + { + InputLimits.Text("content", content, InputLimits.RawApplication); + return DoUpdateAsync(name, MarkdownCodec.Parse(content).Normalized(), expectedVersion); + } public async Task DeleteAsync(string name) { diff --git a/api/ApplyTrack.Api/Data/Criteria.cs b/api/ApplyTrack.Api/Data/Criteria.cs index 0705844..e242387 100644 --- a/api/ApplyTrack.Api/Data/Criteria.cs +++ b/api/ApplyTrack.Api/Data/Criteria.cs @@ -61,6 +61,7 @@ public static Dictionary DefaultSources() => /// Build a normalized Criteria from loose JSON, ignoring junk keys (heir to from_dict). public static Criteria FromJson(JsonElement data) { + InputLimits.ValidateCriteria(data); var lane = GetString(data, "default_lane", "ai").Trim().ToLowerInvariant(); var score = ScoreDefault; diff --git a/api/ApplyTrack.Api/Data/InputLimits.cs b/api/ApplyTrack.Api/Data/InputLimits.cs new file mode 100644 index 0000000..6839a3a --- /dev/null +++ b/api/ApplyTrack.Api/Data/InputLimits.cs @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +using System.Text.Json; + +namespace ApplyTrack.Api.Data; + +/// +/// Human-facing API limits. These bound persisted rows and LLM prompt material at +/// the model edge; request-byte limits are enforced separately by middleware. +/// +public static class InputLimits +{ + public const int Company = 256; + public const int Role = 256; + public const int Lane = 32; + public const int Status = 32; + public const int Link = 2048; + public const int ApplicationField = 512; + public const int ContactEmail = 320; + public const int ApplicationMetadata = 128; + public const int Notes = 64 * 1024; + public const int RawApplication = Notes + 8 * 1024; + + public const int Keywords = 100; + public const int Keyword = 100; + public const int ExcludedLocations = 100; + public const int ExcludedLocation = 200; + public const int AtsBoards = 50; + public const int AtsSlug = 200; + + public const int ResumeExperience = 50; + public const int ResumeHighlights = 50; + public const int ResumeSkills = 200; + public const int ResumeCertifications = 100; + public const int ResumeLinks = 50; + public const int ResumeHeading = 300; + public const int ResumeSummary = 256 * 1024; + public const int ResumeItem = 2000; + + public const int LlmBaseUrl = 2048; + public const int LlmModel = 200; + public const int LlmApiKey = 8192; + public const int CoverLetterSignature = 16 * 1024; + + public static void ValidateApplication(AppFields fields) + { + Text("company", fields.Company, Company); + Text("role", fields.Role, Role); + Text("lane", fields.Lane, Lane); + Text("status", fields.Status, Status); + Text("link", fields.Link, Link); + Text("location", fields.Location, ApplicationField); + Text("salary", fields.Salary, ApplicationField); + Text("source", fields.Source, ApplicationField); + Text("contact", fields.Contact, ApplicationField); + Text("contact_email", fields.ContactEmail, ContactEmail); + Text("applied", fields.Applied, ApplicationMetadata); + Text("followup", fields.Followup, ApplicationMetadata); + Text("created", fields.Created, ApplicationMetadata); + Text("score", fields.Score, ApplicationMetadata); + Text("notes", fields.Notes, Notes); + } + + public static void ValidateCriteria(JsonElement data) + { + Array(data, "keywords", Keywords, Keyword); + Array(data, "exclude_locations", ExcludedLocations, ExcludedLocation); + + if (data.ValueKind != JsonValueKind.Object + || !data.TryGetProperty("ats_boards", out var boards) + || boards.ValueKind != JsonValueKind.Array) + return; + + Count("ats_boards", boards.GetArrayLength(), AtsBoards); + foreach (var board in boards.EnumerateArray()) + { + if (board.ValueKind != JsonValueKind.Object) continue; + JsonText(board, "provider", Keyword); + JsonText(board, "slug", AtsSlug); + } + } + + public static void ValidateResume(JsonElement data) + { + JsonText(data, "full_name", ResumeHeading); + JsonText(data, "headline", ResumeHeading); + JsonText(data, "location", ResumeHeading); + JsonText(data, "summary", ResumeSummary); + Array(data, "skills", ResumeSkills, ResumeItem); + Array(data, "certifications", ResumeCertifications, ResumeItem); + + if (data.ValueKind != JsonValueKind.Object) + return; + + if (data.TryGetProperty("experience", out var experience) + && experience.ValueKind == JsonValueKind.Array) + { + Count("experience", experience.GetArrayLength(), ResumeExperience); + foreach (var entry in experience.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) continue; + JsonText(entry, "company", ResumeHeading); + JsonText(entry, "title", ResumeHeading); + JsonText(entry, "dates", ResumeHeading); + Array(entry, "highlights", ResumeHighlights, ResumeItem); + } + } + + if (data.TryGetProperty("links", out var links) && links.ValueKind == JsonValueKind.Array) + { + Count("links", links.GetArrayLength(), ResumeLinks); + foreach (var entry in links.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) continue; + JsonText(entry, "label", ResumeHeading); + JsonText(entry, "url", Link); + } + } + } + + public static string Text(string field, string? value, int maximum) + { + var text = value ?? ""; + if (text.Length > maximum) + throw new AppValidationException( + $"field '{field}' exceeds the maximum length of {maximum} characters"); + return text; + } + + public static void Count(string field, int count, int maximum) + { + if (count > maximum) + throw new AppValidationException( + $"field '{field}' accepts at most {maximum} items"); + } + + private static void Array(JsonElement data, string field, int maximum, int itemMaximum) + { + if (data.ValueKind != JsonValueKind.Object + || !data.TryGetProperty(field, out var array) + || array.ValueKind != JsonValueKind.Array) + return; + + Count(field, array.GetArrayLength(), maximum); + foreach (var item in array.EnumerateArray()) + { + var value = item.ValueKind == JsonValueKind.String + ? item.GetString() ?? "" + : item.ToString(); + Text($"{field} item", value, itemMaximum); + } + } + + private static void JsonText(JsonElement data, string field, int maximum) + { + if (data.ValueKind != JsonValueKind.Object + || !data.TryGetProperty(field, out var value)) + return; + Text(field, value.ValueKind == JsonValueKind.String + ? value.GetString() + : value.ToString(), maximum); + } +} diff --git a/api/ApplyTrack.Api/Data/Resume.cs b/api/ApplyTrack.Api/Data/Resume.cs index f3b567a..b7e5124 100644 --- a/api/ApplyTrack.Api/Data/Resume.cs +++ b/api/ApplyTrack.Api/Data/Resume.cs @@ -39,6 +39,7 @@ public sealed class Resume /// Build a normalized Resume from loose JSON, ignoring junk keys (mirrors Criteria.FromJson). public static Resume FromJson(JsonElement data) { + InputLimits.ValidateResume(data); return new Resume { FullName = GetString(data, "full_name"), diff --git a/api/ApplyTrack.Api/Endpoints/BlacklistEndpoints.cs b/api/ApplyTrack.Api/Endpoints/BlacklistEndpoints.cs index e0e3c5f..d8dd530 100644 --- a/api/ApplyTrack.Api/Endpoints/BlacklistEndpoints.cs +++ b/api/ApplyTrack.Api/Endpoints/BlacklistEndpoints.cs @@ -25,6 +25,7 @@ public static void MapBlacklistEndpoints(this IEndpointRouteBuilder app) var company = (payload.Company ?? "").Trim(); if (company.Length == 0) throw new AppValidationException("company is required"); + InputLimits.Text("company", company, InputLimits.Company); var added = await bl.AddAsync(company); var passed = await bl.PassOpenLeadsAsync(company); return Results.Ok(new { company, added, passed }); diff --git a/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs b/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs index 7432abb..23f2198 100644 --- a/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs +++ b/api/ApplyTrack.Api/Endpoints/MaterialsEndpoints.cs @@ -85,6 +85,8 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app) { var baseUrl = GetString(payload, "base_url"); var model = GetString(payload, "model"); + InputLimits.Text("base_url", baseUrl, InputLimits.LlmBaseUrl); + InputLimits.Text("model", model, InputLimits.LlmModel); LlmEndpointPolicy.ValidateTenantBaseUrl(baseUrl); // Distinguish "api_key omitted" (leave the stored key alone) from @@ -92,6 +94,8 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app) var changeKey = payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("api_key", out _); var newKey = changeKey ? GetString(payload, "api_key") : null; + if (newKey is not null) + InputLimits.Text("api_key", newKey, InputLimits.LlmApiKey); // Same omitted-means-keep semantics for the cover-letter toggle. bool? lettersEnabled = payload.ValueKind == JsonValueKind.Object @@ -105,6 +109,9 @@ public static void MapMaterialsEndpoints(this IEndpointRouteBuilder app) && sig.ValueKind == JsonValueKind.String ? sig.GetString()?.Trim() : null; + if (signature is not null) + InputLimits.Text( + "cover_letter_signature", signature, InputLimits.CoverLetterSignature); await repo.UpsertAsync(baseUrl, model, changeKey, newKey, lettersEnabled, signature); diff --git a/api/ApplyTrack.Api/Middleware/JsonBodyLimitMiddleware.cs b/api/ApplyTrack.Api/Middleware/JsonBodyLimitMiddleware.cs new file mode 100644 index 0000000..6ef0997 --- /dev/null +++ b/api/ApplyTrack.Api/Middleware/JsonBodyLimitMiddleware.cs @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright 2026 Aaron K. Clark + +using Microsoft.AspNetCore.Http.Features; + +namespace ApplyTrack.Api.Middleware; + +/// +/// Caps ordinary JSON mutations before model binding. Large account imports and +/// multipart résumé uploads retain their purpose-built limits. +/// +public sealed class JsonBodyLimitMiddleware(RequestDelegate next) +{ + public const long MaxBytes = 1024 * 1024; + + public async Task InvokeAsync(HttpContext context) + { + if (!ShouldLimit(context.Request)) + { + await next(context); + return; + } + + var feature = context.Features.Get(); + if (feature is { IsReadOnly: false }) + feature.MaxRequestBodySize = MaxBytes; + + if (context.Request.ContentLength > MaxBytes) + { + await Reject(context); + return; + } + + try + { + await next(context); + } + catch (BadHttpRequestException ex) when ( + ex.StatusCode == StatusCodes.Status413RequestEntityTooLarge + && !context.Response.HasStarted) + { + await Reject(context); + } + } + + private static bool ShouldLimit(HttpRequest request) => + request.Path.StartsWithSegments("/api") + && request.Path != "/api/account/import" + && request.Method is "POST" or "PUT" or "PATCH" + && request.ContentType?.StartsWith( + "application/json", StringComparison.OrdinalIgnoreCase) == true; + + private static async Task Reject(HttpContext context) + { + context.Response.Clear(); + context.Response.StatusCode = StatusCodes.Status413RequestEntityTooLarge; + await context.Response.WriteAsJsonAsync(new + { + detail = $"JSON request body exceeds the {MaxBytes / 1024} KiB limit", + }); + } +} diff --git a/api/ApplyTrack.Api/Program.cs b/api/ApplyTrack.Api/Program.cs index d578c9c..05b1a40 100644 --- a/api/ApplyTrack.Api/Program.cs +++ b/api/ApplyTrack.Api/Program.cs @@ -192,6 +192,10 @@ static string ClientPartition(HttpContext ctx) => // get the FastAPI-compatible {"detail": "..."} body + status the SPA expects. app.UseMiddleware(); +// Bound ordinary JSON mutations before Minimal API model binding. Account import +// and résumé PDF upload keep their separate, larger purpose-built limits. +app.UseMiddleware(); + // Serve the vanilla-JS SPA verbatim from wwwroot (index.html as the default doc). // Static files short-circuit before the tenancy middleware, so the shell loads // without a session and the SPA's own login gate handles the 401s on /api. diff --git a/pyproject.toml b/pyproject.toml index 673822c..0f0d04e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "applytrack-poller" -version = "1.11.3" +version = "1.11.4" description = "Discovery poller for OSApplyTrack — fetches and scores remote job leads into shared Postgres." requires-python = ">=3.10" license = { text = "Apache-2.0" } diff --git a/uv.lock b/uv.lock index b4000b0..051528d 100644 --- a/uv.lock +++ b/uv.lock @@ -23,7 +23,7 @@ wheels = [ [[package]] name = "applytrack-poller" -version = "1.11.3" +version = "1.11.4" source = { editable = "." } dependencies = [ { name = "defusedxml" },