# OSApplyTrack Backlog This file mirrors the open [GitHub Issues](https://github.com/CryptoJones/OSApplyTrack/issues) for the project. Check an item only when its matching issue is closed. Deferred ideas in `README.md` or `SPRINTS.md` are not committed backlog until they have a corresponding issue. ## Security and stability - [x] [#228 — [SEC] Magic-link sign-in is broken on the live instance: POST /api/auth/request returns 500 for every valid email](https://github.com/CryptoJones/OSApplyTrack/issues/228) (fixed in #245: fail loudly at boot on a misconfigured SMTP sender instead of a per-request 500) - [x] [#229 — [SEC] Session cookie issued without Secure flag: app layer does not see the request as HTTPS behind the TLS proxy](https://github.com/CryptoJones/OSApplyTrack/issues/229) (fixed in #247: surface a dropped X-Forwarded-Proto so Secure cookies + HSTS aren't silently off) - [x] [#230 — [SEC] Duplicate and conflicting security headers emitted by both edge and app](https://github.com/CryptoJones/OSApplyTrack/issues/230) (the app is the single source of truth for the header set — README now tells operators not to re-add them at the reverse proxy; the live pluto edge stops re-adding the duplicates) - [x] [#231 — [SEC] DNS-rebinding TOCTOU in the IMAP mailbox host guard](https://github.com/CryptoJones/OSApplyTrack/issues/231) (fixed in 1.39.4: the mailbox resolves the host once and dials a socket pinned to that resolution's public addresses, handed to MailKit already connected — no second resolution at connect time, the same no-TOCTOU pattern as the scrape path in #51) - [x] [#232 — [SEC] Minor information disclosure: build version, DB status, and nginx version on unauthenticated surfaces](https://github.com/CryptoJones/OSApplyTrack/issues/232) (decision recorded: `/health` build version and `/health/ready` DB status stay — both serve a purpose and are documented as accepted disclosures in code; the nginx version banner is turned off at the edge with `server_tokens off;`, documented in the README) - [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) - [x] [#50 — Add global JSON body caps and per-field/cardinality limits](https://github.com/CryptoJones/OSApplyTrack/issues/50) - [x] [#52 — Serialize overlapping tenant poll runs](https://github.com/CryptoJones/OSApplyTrack/issues/52) - [x] [#266 — Scrape: the fetch has no whole-fetch budget, so a slow board or a redirect chain stacks past the per-hop timeout](https://github.com/CryptoJones/OSApplyTrack/issues/266) (fixed in [#268](https://github.com/CryptoJones/OSApplyTrack/pull/268): every hop and the body read draw on one 15s clock, and `HttpClient.Timeout` is Infinite so that clock is the only ceiling — at 10s it fired first on a single slow hop and made the class's own documentation untrue. Cancellation is classified by token rather than exception type, so a stopped worker or a closed tab is no longer reported as a board failure, and the middleware no longer logs a client abort as an unhandled 500. A refused connection, NXDOMAIN or TLS failure still says "couldn't reach that page" instead of claiming a timeout. Reviewed by the Flatline Roundtable — 13 of 14 lanes, which caught that last one) - [x] [#117 — Encrypt sensitive columns at rest, secure by default for every deploy](https://github.com/CryptoJones/OSApplyTrack/issues/117) (phase 1 shipped in 1.26.0; `notes`, `email`, `contact_email`, `phone` and the extracted résumé fields stay deferred) - [x] [#118 — Public beta terms, acceptance at sign-in, and a typed-confirmation data delete](https://github.com/CryptoJones/OSApplyTrack/issues/118) - [x] [#120 — Saved answers reused across fields that ask for different units, periods or currencies](https://github.com/CryptoJones/OSApplyTrack/issues/120) - [x] [#121 — Optional questions get filled with placeholder answers instead of left blank](https://github.com/CryptoJones/OSApplyTrack/issues/121) - [x] [#337 — [SEC] Magic-link host-header poisoning on the quickstart compose and quadlet deploys](https://github.com/CryptoJones/OSApplyTrack/issues/337) (fixed in 1.55.6: sign-in links fall back to the request Host only when it is loopback, the api refuses to boot with SMTP but no `App:PublicBaseUrl`, and the quickstart compose + quadlet env template now carry `App__PublicBaseUrl`/`AllowedHosts`) - [x] [#338 — [SEC] Poller: fetch_public / probe read whole bodies before the size cap and have no overall deadline](https://github.com/CryptoJones/OSApplyTrack/issues/338) (fixed in 1.55.10: bodies are streamed and abandoned at the 2 MB cap, probes never read a body, and every fetch/probe has a 15s wall-clock deadline clamped onto each socket read and DNS lookup; gzip/deflate are inflated in bounded steps so a compression bomb stops at the cap too) - [x] [#339 — [SEC] Poller SSRF guard admits CGNAT (100.64/10) and other non-global ranges](https://github.com/CryptoJones/OSApplyTrack/issues/339) (fixed in 1.55.11: the poller requires `is_global`, so CGNAT/Tailscale, TEST-NETs and the rest of the special-purpose ranges are refused; NAT64, 6to4, Teredo and IPv4-compatible addresses are unwrapped like the API's; redirect hops are held to ports 80/443; both guards now run one shared parity fixture, and the API now unwraps Teredo and refuses the TEST-NET and IPv6 documentation ranges too) - [x] [#340 — Poller never receives APPLYTRACK_SECRETS_KEY, so sealed board sessions silently fail to decrypt](https://github.com/CryptoJones/OSApplyTrack/issues/340) (shipped in 1.55.5: both compose files and the quadlet hand the poller the key — env or the api's key file, mounted read-only; seal/unseal refuse an empty key, bad base64 is a SealError, and a session that won't unseal logs a WARNING) - [x] [#341 — [SEC] Login CSRF: GET /api/auth/verify signs any browser into the token's account; link scanners burn tokens](https://github.com/CryptoJones/OSApplyTrack/issues/341) (fixed in 1.55.7: the link opens a confirm page that POSTs the token, so scanners can't spend it; tokens are bound to a pre-auth cookie set by the request, so only the requesting browser can sign in; a successful sign-in replaces that browser's old session) - [x] [#342 — [SEC] Prompt-injected postings can exfiltrate résumé data via markdown images in the cover-letter preview](https://github.com/CryptoJones/OSApplyTrack/issues/342) (fixed in 1.55.8: CSP `img-src` is now `'self' data:` only, the SPA renders cover letters and notes with a DOMPurify config that drops images/media/`style`, and the drafter strips image markup from the model reply and tells it to ignore instructions in the posting) - [x] [#343 — [SEC] Board-account domain matching uses a two-label approximation](https://github.com/CryptoJones/OSApplyTrack/issues/343) (fixed in 1.55.9: registrable domains now come from the Public Suffix List, private section included, embedded in the API — board-account matching, the top-level navigation allowlist and the sign-in-mail host check no longer treat `evil.co.uk` as `acme.co.uk` or one `*.github.io`/`*.azurewebsites.net` customer as another, and an account saved for a bare public suffix covers nothing beneath it) - [x] [#344 — [SEC] Résumé PDF upload and account import: unbounded parse, chunked bodies skip the size gate, no rate limit](https://github.com/CryptoJones/OSApplyTrack/issues/344) (fixed in 1.55.12: résumé upload and account import are throttled per IP and per tenant (10 per 5 min each); a chunked body is capped as it streams instead of falling through to Kestrel's 30 MB default; the PDF parse runs off the request thread under a 10 s budget, reads at most 20 pages and stops at the text cap; any parse failure is a 400) - [x] [#345 — [SEC] Least-privilege gaps: poller as schema owner; quadlet agent as owner without hardening; table-wide agent UPDATE](https://github.com/CryptoJones/OSApplyTrack/issues/345) (fixed in 1.55.13: the poller connects as its own `applytrack_poller` role (only the tables and columns it touches; no sessions, tokens, résumés, board passwords or DDL); the api creates and re-passwords both restricted roles on boot from `AGENT_DB_PASSWORD`/`POLLER_DB_PASSWORD`; the agent's `UPDATE` on applications is column-level, never `tenant_id`/`name`; the quadlets give the agent and poller their own env files, run api/agent/poller read-only as uid 1654 with no capabilities, and publish the api on 127.0.0.1:8080 only — see deploy/quadlet/README.md for the upgrade steps) - [x] [#346 — [SEC] Hardening grab-bag: hashed session IDs, users.status, sign-out-everywhere, per-tenant LLM limit, slug validation, URL userinfo in logs](https://github.com/CryptoJones/OSApplyTrack/issues/346) (fixed in 1.56.0: sessions are stored as the SHA-256 of the cookie's id — existing rows are hashed in place, so nobody is signed out; a session slides 30 days from last use, capped at 90 from sign-in; `users.status` other than `active` stops sessions and sign-in links; `GET`/`DELETE /api/account/sessions` and Settings · Account list sessions and sign out everywhere else; the model routes are throttled per tenant as well as per IP, and `Llm__DailyCapPerTenant` (default 200) caps requests on the operator's key; Greenhouse/Lever slugs must match `[A-Za-z0-9_-]{1,100}` in both runtimes; LLM log lines carry only `scheme://host:port`) - [x] [#390 — [SEC] uv.lock pins anyio 4.14.0 — pip-audit flags three CVEs (fixed in 4.14.2)](https://github.com/CryptoJones/OSApplyTrack/issues/390) (fixed in 1.65.2: uv.lock now pins anyio 4.14.2 on Python 3.10 and 4.15.1 on 3.11+, and CI also audits the locked set — `uv export --locked`, markers stripped so every Python-version fork of the lock is checked, not just the runner's — so the lock can no longer drift vulnerable behind a clean fresh resolution) - [x] [#459 — CI failed on main: .NET, Python and Web audit jobs](https://github.com/CryptoJones/OSApplyTrack/issues/459) (fixed on main, no release needed — the three weekly-run failures were all newly published advisories against existing dependencies, not code: MailKit → MimeKit pulled `System.Security.Cryptography.Pkcs` 10.0.0 (GHSA-846f-f764-76mx high, GHSA-2fmv-rv2c-59x7 moderate), so the api project now carries a direct reference to 10.0.14 as a transitive floor; `uv.lock` moves certifi 2026.6.17 → 2026.10.23 (PYSEC-2026-4320); `package-lock.json` moves follow-redirects 1.16.0 → 1.16.2 and qs 6.15.3 → 6.17.1 via `npm audit fix`. The patched Pkcs ships in the next tagged release) - [x] [#392 — Agent can't start Playwright as uid 1654: /app/.playwright/node/linux-x64/node is mode 744 root (breaks every packet build since the #345 units)](https://github.com/CryptoJones/OSApplyTrack/issues/392) (fixed in 1.65.3: the api image makes the Playwright driver tree readable by all and its node binary executable by all (`chmod -R a+rX /app/.playwright`), so the agent under the #345 hardening — uid 1654, read-only root, noexec `/tmp`, no capabilities — starts the driver again. Verified by driving `Playwright.CreateAsync()` → `Chromium.ConnectAsync()` → a screenshot from the image under exactly that profile: EACCES before, clean after; the driver needs nothing writable beyond `/tmp` (a read-only `$HOME` is fine). A new `image` CI job builds the api image and starts the driver that way on every PR. Deploy: pull 1.65.3 and put the agent back on `User=1654:1654`) ## Operations and scalability - [x] [#53 — Add hardened production container defaults](https://github.com/CryptoJones/OSApplyTrack/issues/53) - [x] [#54 — Paginate or delta-refresh the applications list](https://github.com/CryptoJones/OSApplyTrack/issues/54) - [x] [#88 — Poller: `_fetch_feed_set` parses up to the whole-run limit on every category feed](https://github.com/CryptoJones/OSApplyTrack/issues/88) - [x] [#100 — CI is failing on main](https://github.com/CryptoJones/OSApplyTrack/issues/100) (auto-filed; a boot-order race in the SPA after #98) - [x] [#89 — `uv.lock`: exceptiongroup's typing-extensions marker was dropped in the 1.16.1 release commit](https://github.com/CryptoJones/OSApplyTrack/issues/89) - [x] [#347 — [PERF] Poller: link checks and employer resolution repeat per tenant, sequentially; sources fetched one at a time](https://github.com/CryptoJones/OSApplyTrack/issues/347) (fixed in 1.56.1: a poll run keeps one per-URL cache of employer resolutions and reachability probes across every tenant, so a shared listing is resolved and probed once per run, not once per tenant; a tenant's candidates are resolved and probed on a bounded pool (8 at once, at most 2 per host), and the worker's source gather runs the same way; custom feeds share one SSRF-pinned client per pool thread instead of a new client and TLS handshake per feed) - [x] [#348 — [PERF] Poller: full dedupe history reloaded every run, one round-trip per seen key, Jev scored before cheap filters](https://github.com/CryptoJones/OSApplyTrack/issues/348) (fixed in 1.56.2: an empty batch reads nothing from the database; otherwise only the batch's own URL and slug keys are read from `seen` (a primary-key lookup) instead of the tenant's whole history, and the run's newly seen keys are written in one statement at the end instead of one autocommit per listing; LinkedIn/Handshake `seen_url` reads the ledger's URL keys once per run and answers from memory; the location and dead-end filters run before any Jev call, and Jev is asked 4 at a time after a first canary call, so an outage still costs one timeout) - [x] [#349 — [PERF] SPA's 5-second idle poll always runs the heavy /api/errors query](https://github.com/CryptoJones/OSApplyTrack/issues/349) (fixed in 1.56.3: `/api/errors` carries its own ETag, a single cheap fingerprint of what the view reads — the applications revision, the evidence written, the queue's pending set, the newest packet, the failures still inside the retry window, and the two agent settings that decide a row's next step — and the SPA asks for it with `If-None-Match`, so an unchanged view is a 304 instead of the per-row query; migration 0044 indexes the evidence and packet reads the fingerprint makes) - [x] [#350 — [PERF] No data retention for evidence screenshots, sessions, magic tokens, agent_events and seen keys](https://github.com/CryptoJones/OSApplyTrack/issues/350) (fixed in 1.56.4: a daily sweep in the migrating api container, per tenant and idempotent — evidence screenshots older than `Retention__ScreenshotDays` (90) lose their bytes while the row and its text stay, never a submission's, an application's newest `Retention__ScreenshotsKeptPerApplication` (3), or any while it is in Ready/Errors; sessions and magic-link tokens go `Retention__ExpiredGraceDays` (1) after expiry; agent events older than `Retention__AgentEventDays` (180) go except verdicts, packet builds, accounts created and events about a live lead or Ready application; the `seen` ledger is kept unless `Retention__SeenDays` is set (default 0 = off); documented in README) - [x] [#351 — [PERF] N+1 and row-by-row writes: /api/pipeline, ReadyPromoter, bulk Ready actions, per-row revision trigger on import](https://github.com/CryptoJones/OSApplyTrack/issues/351) (fixed in 1.56.5: `/api/pipeline`, the Ready promotion pass and bulk Submit read every packet's gates in one query of the provider, questions and review list — no sealed columns decrypted; bulk Pass is one `UPDATE … RETURNING`, and Prepare/Submit read the names' status and link in one query instead of `SELECT *` each; the account import writes applications in `unnest` batches of 1000; migration 0045 makes the list-revision trigger statement-level with transition tables, so a 10k import bumps the tenant row ten times, not 10k) - [x] [#352 — [PERF] Small wins: compression, pooled LLM handler, cacheable screenshots, evidence partial index, notes snippet, pool size](https://github.com/CryptoJones/OSApplyTrack/issues/352) (fixed in 1.56.6: Brotli/gzip for the SPA's static files and the polled `/api/apps`, `/api/errors`, `/api/pipeline` lists only — every other API response stays uncompressed against BREACH; tenant LLM endpoints share one pooled, SSRF-guarded handler with a two-minute connection lifetime, no shared cookie jar, and a per-call deadline that covers the body read; screenshots are `private, max-age=86400, immutable` with an ETag from the evidence id; migration 0046 adds a partial index for submitted evidence; the list reads the first 1024 chars of whitespace-collapsed notes; the agent worker's pool defaults to 8) - [x] [#358 — Feature: operator admin CLI (users, allowlist, usage, disable) and a Backup & restore runbook](https://github.com/CryptoJones/OSApplyTrack/issues/358) (shipped in 1.63.0: `dotnet ApplyTrack.Api.dll admin users|usage|allow|disallow|disable|enable`, run in the api container with the owner's connection — the agent's and poller's roles are refused; `usage` shows per-account applications, letters, evidence rows and bytes, live sessions, last sign-in, last seen and last poll, which the poller now stamps in `users.last_polled_at` (migration 0052, column grant to `applytrack_poller`); `disable` sets `users.status`, and the agent now skips a disabled account's passes and queued runs too; `docs/operations.md` is the backup & restore runbook — `pg_dump` plus custody of `APPLYTRACK_SECRETS_KEY` or the key file, compose and quadlet, a restore that lets the api recreate the roles, the retention sweep on an old dump, and a restore drill) - [x] [#359 — Feature: observability — opt-in OpenTelemetry, /metrics, and a poll_runs table](https://github.com/CryptoJones/OSApplyTrack/issues/359) (shipped in 1.64.0: the poller writes a `poll_runs` row per account per pass — started/finished, `ats_only`, `leads_added`, and `errors` naming only that account's failing sources (migration 0053; `applytrack_poller` gets INSERT on it and nothing else) — read by `GET /api/poll/status` and shown under the Applications heading as "Last polled 12 min ago · 2 sources failing" with the reasons in a keyboard disclosure; pruned by `Retention__PollRunDays` (30, never an account's newest pass of either kind); `users.last_polled_at` stays the operator's cross-account stamp. Opt-in OpenTelemetry via `OTEL_EXPORTER_OTLP_ENDPOINT` — ASP.NET Core, HttpClient and Npgsql traces (route templates, host-only outbound URLs, Telegram untraced) plus metrics — and a Prometheus `/metrics` only on `Metrics__Port`, never the app's port; both off by default. Custom meters: LLM call latency and errors, agent runs by ATS provider and outcome, submit-queue depth; no metric carries a tenant, host or URL) ## Discovery and workflow - [x] [#310 — Agent: the person answers the same question again whenever a form words it differently](https://github.com/CryptoJones/OSApplyTrack/issues/310) (the bank matched only exact wording and the drafter never saw the person's own answers — five hand-typed answers to "how did you hear about…" on the live tenant; fixed in 1.53.2: the drafter's prompt carries the person's past answers and reuses one for the same question reworded, never for a merely similar one) - [x] [#308 — Agent: a clean Easy Apply dry run with nothing to fill is never promoted](https://github.com/CryptoJones/OSApplyTrack/issues/308) (the #302 "mapped nothing" bar parked every Easy Apply dialog that only shows pre-filled contact details — fixed in 1.53.2: a dry run that walked the dialog to LinkedIn's own Submit records `reached_submit`, which stands in for "something was mapped". Also 1.53.2: a posting whose Apply is LinkedIn's "Apply ↗ — Responses managed off LinkedIn" button — eleven leads on 09-23 read "no Apply of any kind" — now has the address that button opens read from the page's own `window.open` (nothing is opened or sent), and the lead moves to the employer's link with its packet rebuilt; a LinkedIn listing staged as "aggregator", which nothing drove, goes to the LinkedIn driver when Easy Apply is on. **1.54.1:** on live LinkedIn that Apply is an `` through LinkedIn's own interstitial, `linkedin.com/safety/go/?url=` — the check had discarded every linkedin.com link, so 16 leads still read "no Apply of any kind"; the employer's address is now read from its `url=`) - [x] [#314 — Agent: Ready-queue defects found 2026-09-23](https://github.com/CryptoJones/OSApplyTrack/issues/314) (**1.54.2:** a native date/month/datetime-local input gets its ISO form and a refused fill no longer ends the run (evlo); "Submit Application" is found from the DOM when an aria-modal panel hides it from role queries (ElevenLabs); an email step with a terms box is a sign-in and the box is ticked before Continue (DTCC, Oracle Recruiting); a `?gh_jid=` employer page that shows no form opens Greenhouse's own copy (Bridgeway); Easy Apply's City / Location typeahead falls back to the résumé location (SMX). **1.54.3**, from the first live runs on 1.54.2 — Bridgeway and Teachstone **submitted**: Oracle's email step kept an aria-hidden `tabindex=-1` honeypot on screen, which hid the sign-in from the email-only test, and its terms box is drawn over an input neither `check()` nor a forced one can reach — the input's own `click()` is what its binding hears (DTCC, verified on the live page); and LinkedIn's City typeahead now clicks the suggestion its list shows, trying the place's head ("Minden") when the full text finds nothing, instead of ArrowDown + Enter blind (SMX); and a page whose Next stays disabled hands its unanswered questions back to the drafter even when none is marked required — evlo's "Willing to relocate?" had no star, and Next waited on it (verified on the live form). **1.54.4**, from the 1.54.3 runs — DTCC now signs in (terms ticked, code from the mailbox): discovery skips honeypots too (Oracle's `honey-pot` had become a question the model could not answer, blocking the packet); a radio group titled by a bare starred `