# OSApplyTrack โ€” sprint tracker Lightweight status board for the build. **[`plan.md`](./plan.md) is the source of truth** for scope and design; this file just tracks where each sprint stands. Status legend: โœ… done ยท ๐Ÿšง in progress ยท โฌœ not started | Sprint | Scope | Status | | ------ | ----- | ------ | | **Step 0** | Test scaffolding + repo split + license setup | โœ… done | | **Step 1** | .NET API skeleton + Postgres storage; serve the SPA verbatim (single-user, bootstrap `tenant_id=1`) | โœ… done | | **Step 2** | Auth + tenancy spine (magic-link, server-side sessions, tenant choke-point) | โœ… done | | **Step 3** | Per-user search profiles (Python poller reads them) | โœ… done | | **Step 4** | Per-tenant cron + `seen` table (Python worker, fetch-once-per-run) | โœ… done | | **Step 5** | Self-host packaging + data export/delete (replaces billing) | โœ… done | | **Step 6** | Encryption at rest, operator allowlist & captcha code fallback (v1.26โ€“v1.27) | โœ… done | | **Step 7** | Answer bank, two-way Telegram codes & worker heartbeats (v1.28โ€“v1.29) | โœ… done | | **Step 8** | Ready lane in bulk, queued prepare & salary unit conversions (v1.30) | โœ… done | | **Step 9** | Mobile list scroll, cookie consent, editable name fields & upload verifications (v1.31) | โœ… done | | **Step 10** | Pipeline view & "Apply later" trap prevention (v1.32) | โœ… done | ## Step 0 โ€” done Shipped: - **Python poller ported** from the original applytrack, trimmed to the poller + CLI (`src/applytrack/`), 27 pytest tests green (criteria/poll/fetchers), offline `run_poll(listings=)` seam preserved. - **Empty .NET 10 solution** (`api/ApplyTrack.slnx`) with `ApplyTrack.Api` (Dapper + Npgsql + DbUp) and a DbUp migration runner (`Data/Migrator.cs`, first migration `0000_extensions.sql` enabling `citext`). `/health` endpoint. - **.NET test project** (`ApplyTrack.Api.Tests`) โ€” xUnit + Testcontainers-Postgres, real DbUp upgrade in the fixture; 2 tests green. - **Apache-2.0** license set up: root `LICENSE`, `pyproject.toml`, `.csproj` metadata, SPDX headers on every source file. - **Runs locally *and* as a container:** env-driven connection string; `docker-compose.yml` (Postgres + API) + `Dockerfile`; both run modes verified. ## Step 1 โ€” done The first user-visible milestone: the existing vanilla-JS SPA loads and works **identically**, now served by .NET off Postgres (bootstrap `tenant_id=1`, no auth). Shipped: - **Schema** (DbUp): `applications` (15 free-text fields + `version` for optimistic locking, `UNIQUE(tenant_id,name)`, `(tenant_id,status)` index), `search_profiles` (criteria columns the Python poller reads in Steps 3-4), `blacklist`. - **Domain + codec ported to C#:** `AppFields`, `Slug` (the `safe_name` heir), `MarkdownCodec` (frontmatter render/parse), `Criteria` (loose-JSON normalizer). - **Tenant-scoped Dapper repos:** `ApplicationRepo` (CRUD, list ordering, stats, `version`-column optimistic lock โ†’ 409), `CriteriaRepo` (defaults-if-absent + upsert), `BlacklistRepo` (normalized keys + pass-open-leads). - **Minimal API** written against the SPA contract byte-for-byte: snake_case JSON, `?expected_version=` 409 flow, FastAPI-style `{"detail"}` errors. `poll` / `check-link` / `draft` answer 501 (out of v1). SPA copied verbatim to `wwwroot/`. - **Migration CLI (Python):** `applytrack import-md --dir โ€ฆ --tenant 1` reuses `parse_app` to upsert legacy Markdown into Postgres (psycopg3). - **Verified:** 29 .NET tests + 30 pytest green; end-to-end curl sweep of every endpoint; 36 real legacy apps imported and served by the API. (No in-browser visual test โ€” headless environment.) ## Steps 2โ€“5 โ€” done All four shipped; **v1 is feature-complete** (every `plan.md` build step is done). - **Step 2 โ€” Auth + tenancy spine** (`245bc54`): magic-link request/verify, single-use SHA-256 tokens (15-min TTL), opaque server-side sessions (30-day, instant revocation on logout), and the tenant choke-point middleware that hands every endpoint a pre-scoped repo. No account enumeration (`/api/auth/request` is always 200). - **Step 3 โ€” Per-user search profiles** (`4ea1e62`): the Python poller reads each tenant's `search_profiles` row and scores listings against it; `/api/criteria` round-trips the same snake_case shape the SPA and poller share. - **Step 4 โ€” Per-tenant cron + seen ledger** (`4119d91`, `ca15974`): the worker fetches sources once per pass and fans out across active tenants, deduping against the `seen` table; the on-demand `/api/poll` enqueues to `poll_requests`, drained on the fast lane. - **Step 5 โ€” Self-host packaging + export/delete** (`4cc48a1`): three-service `docker compose up` (db + api + poller); `GET /api/account/export` (a zip of Markdown + `settings.json`) and `DELETE /api/account` (`ON DELETE CASCADE`). ## Post-v1 hardening & polish - **Security** (`805664e`): fixes for a cross-edition OWASP Top 10 audit โ€” an SSRF-hardened link probe, DOMPurify on rendered notes, a strict CSP + security- header middleware, per-IP rate limits, generic 500s, and a dependency-audit CI. - **Docs** (`9d9ccf4`, `3f91e2c`, `7f7f596`): an extensive README (tagline, badge row, full API reference, data model, security section) + a dashboard screenshot. ## Post-v1 polish - โœ… **Pipeline grand-total pill** โ€” the dashboard pipeline strip now renders an accent `N total` pill flush-right of the per-status counts (`renderPipeline()` in `app.js`, `.pipe-total` in `app.css`). Distinct from the footer `N apps` counter, which still tracks the *filtered* view (`shown/total`). - โœ… **Build is warning-clean** โ€” swapped the obsolete `ForwardedHeadersOptions. KnownNetworks` for `KnownIPNetworks` (.NET 10 `ASPDEPR005`). - โœ… **GitHub CI** (`.github/workflows/ci.yml`) โ€” the Forgejo runner has no Docker, so its workflow only audits deps; GitHub's ubuntu runners do, so CI there runs *both* suites (Testcontainers .NET + pytest/ruff/mypy) plus the NuGet/pip audit. Live CI badge in the README. Its first run caught a stale runner `pip` (PYSEC-2026-196); both workflows now upgrade pip before auditing. - โœ… **v1.0.0 release** (`.github/workflows/release.yml`) โ€” a `v*` tag builds the api + poller images and pushes them to GHCR (`ghcr.io/cryptojones/osapplytrack-{api,poller}`, semver + `latest`, public) and publishes a GitHub Release. Versions bumped to 1.0.0; release + GHCR badges added. - โœ… **Shared opportunity list** (issue #1) โ€” `GET /api/account/export/shared` exports the peer-facing sibling of the migration snapshot (`format: applytrack-shared`): slug + company/role/link/location/source only, personal state stripped server-side. Import branches on the `format` field: every entry lands as a fresh `lead`, slugs already tracked are skipped (new `ApplicationRepo.InsertIfAbsentAsync`, `ON CONFLICT DO NOTHING`). 5 new tests. - โœ… **Settings hub** โ€” one โš™ Settings masthead button replaces the six scattered panel/export buttons; tabs for Criteria, Rรฉsumรฉ, AI, Blacklist, Account. New surfaces that previously had **no UI**: blacklist view/remove (`DELETE /api/blacklist/{company}`), account delete (double-confirm), sign-out. Export / Share / Import live under Account. - โœ… **Port guard rails** โ€” the stack checks before it binds instead of silently winning the race for a contested port (8080: vLLM/llama.cpp/dev tools). Compose gains a host-network `portcheck` preflight that fails the `api` service with a clear message when `API_PORT` is taken; `deploy/quadlet/` now ships example rootless-podman units with the same `ExecStartPre` guard. - โœ… **Cover-letter kill switch** (DbUp 0013) โ€” per-tenant `cover_letters_enabled` on `llm_settings`, surfaced as a checkbox in Settings ยท AI. OFF hides every drafting affordance in the SPA and `POST /api/apps/{name}/draft` refuses with a clear 400, so tenants who don't want to run a model never see or call one. Omitted-means-keep PUT semantics, same as `api_key`. ## Agentic auto-apply - โœ… **Step 1 โ€” the draft reads the posting** (#91): `POST /api/apps/{name}/draft` fetches the job description through the SSRF-hardened fetcher and puts it in the prompt; best-effort, honest "(not available)" when it can't. - โœ… **Step 2 โ€” the agent's own fit verdict** (#92): `agent_settings` (off by default, its own 70-point bar, per-pass/per-day caps, standing answers) and `agent_events` (the audit trail, keyed to the user, so a deleted application never erases the record). `StructuredCompleter` gets JSON out of any chat model with one repair prompt and a hard failure after that; `Disqualifiers` veto in code before a token is spent; `FitJudge` judges against the rรฉsumรฉ brief. `AgentWorker` is the first `BackgroundService` โ€” a second container from the api image with `Agent__Enabled=true`, its own small pool, per-tenant advisory lock under `applytrack:agent:`, and the migrator now serializes on an advisory lock. Settings ยท Agent tab, a Fit-verdict block on the sheet, and `POST /api/apps/{name}/verdict` for judging by hand. Stages nothing. - โœ… **Step 3 โ€” prepared packets and the Ready-to-submit queue** (#93) **+ the moo** (#99): a `proceed` now becomes an `agent_packets` row โ€” the ATS form (Greenhouse's real one via the public Job Board API, the standard set for everyone else), the drafted answers (deterministic ones from your facts, screening ones from the model, anything unanswerable flagged, EEO never touched), the letter โ€” and parks the application in `ready`, the status that was defined but unreachable since the single-user app. The sheet shows the packet with every answer editable (`?expected_version=` 409 flow on the packet's own version), a `role="alert"` summary of what still needs you, and **Copy answers and open the posting** for every ATS. `notification_settings` + `TelegramNotifier`: one ๐Ÿฎ per packet (`notified_at` claim), Settings ยท Notifications with a test button, `#app=` deep links. `POST /api/apps/{name}/packet/prepare` does it by hand. - โœ… **Step 4 โ€” human-triggered browser submission, dry-run by default** (#94): the only step that touches an employer. `Microsoft.Playwright` in the api, driving a **separate** browser container (`docker/browser`, Playwright's image running `run-server`) over `ws://` โ€” the api/agent images keep their hardened profile. Containment: the scraper's URL/address pre-flight, an `internal: true` browser network whose only exit is a squid forward proxy (`docker/proxy`) that does the DNS and refuses private destinations, route interception, and a least-privilege `applytrack_agent` Postgres role (no DELETE, no sessions/magic_tokens; grants in the RunAlways `Migrations/Always/agent_role.sql`, `Migrations__Mode=wait` for the container that cannot migrate). `submit_requests` (claimed by UPDATE โ€ฆ SKIP LOCKED), `agent_evidence` (screenshot + confirmation), the rรฉsumรฉ PDF kept and attached from memory. **Dry run by default:** fill, screenshot, don't click; the moo now says "filled in and ready for you to click Apply". `POST /api/apps/{name}/submit`, `GET โ€ฆ/evidence`, the Submit / dry-run buttons on the sheet. Tests drive the real submitter through a real Playwright server at a loopback fixture form and assert on the POST it receives. - โœ… **Step 5 โ€” Lever, Ashby, and the unknown long tail** (#95): `FormDiscoverer` visits a form read-only in the browser container and enumerates its controls by accessible name โ€” Lever and Ashby (one hop past the posting: `/apply`, `/application`) get real questions this way; the same `PacketQuestion` shape Greenhouse's API gives, so the drafter and the submitter need no per-ATS code. The generic adapter (fill by label, refuse to click on an unmapped required field) is behind the per-tenant `long_tail` opt-in (0023). Workday was routed to copy-and-open until #443 (v1.68.0): its wizard is now walked signed in as the candidate, with the account made on the way under #277's opt-in. - โœ… **Step 6 โ€” Encryption at rest, operator allowlist & captcha code fallback** (#117, #156, #165, #166; v1.26โ€“v1.27): Sensitive columns sealed with AES-256-GCM at rest (rรฉsumรฉs, cover letters, packet answers, evidence screenshots, tenant API keys, bot tokens). Production audit closed out with first confirmed end-to-end Greenhouse submission. Auto-apply gated per account by `agent_allowlist` table (operator opt-in only). Greenhouse 428 captcha fallback: browser session parks, records `awaiting_code`, moos via Telegram, and finishes when candidate submits code via `POST /api/apps/{name}/security-code`. - โœ… **Step 7 โ€” Answer bank, two-way Telegram codes & worker heartbeats** (#170, #172, #173, #174, #175, #178, #181; v1.28โ€“v1.29): **Answer Bank** (`answer_bank` table, `GET/PUT/DELETE /api/answers`, Settings ยท Answers): reusable screening questions and answers across forms, editable once, reused everywhere without model calls. **Two-way Telegram replies**: candidate replies directly to the ๐Ÿ” Telegram moo with the security code; worker polls Telegram updates to resume parked browser runs. **IMAP Mailbox code reader**: parked runs read Greenhouse security codes directly from an IMAP mailbox (`mailbox_settings`). **Worker heartbeat registry**: `agent_workers` table records worker heartbeats so API knows when browser-capable workers are available. Form discovery handles iframes, late renders, single Name boxes, and text-only labels. - โœ… **Step 8 โ€” Ready lane in bulk, queued prepare & salary unit conversions** (#180, #183โ€“#192, #194; v1.30.0): **Bulk Ready lane**: Card checkboxes and bulk action toolbar (**Prepare selected**, **Submit selected**, **Pass selected**, **Submit all clean**; `POST /api/ready/actions`). **Promotion after the flip (`ReadyPromoter`)**: turning dry-run off promotes all clean Ready packets immediately and on each worker pass. **Queued prepare**: `POST /api/apps/{name}/packet/prepare` delegates to worker (`prepare: true`) to run discovery, drafting, and dry run in one claim where the browser is. **Salary unit & currency conversions**: standing answers support `salary_period` (`annual`, `monthly`, `hourly`) and `salary_currency`, converting within currency (annual รท 12, annual รท 2080) and refusing cross-currency. **Aggregator link resolution**: poller follows aggregator listings (RemoteOK, Remotive, We Work Remotely) to employer postings, marking unresolved postings `aggregator`. **Heartbeat timer**: independent 30-second timer for worker heartbeat; UI shows "Worker last seen...". **Parked run test harness**: `IBrowserSubmitter` seam and worker-level tests for parked-on-code, Telegram reply, and mailbox paths. - โœ… **Step 9 โ€” Mobile list scroll, cookie consent, editable name fields & upload verifications** (#195, #199, #200โ€“#205, #206, #207, #209; v1.30.1โ€“v1.31.2): **Mobile scroll & touch**: dedicated scroll container with `overscroll-behavior: contain` prevents pull-to-refresh swipe interference on mobile Safari (#201). **Terms checkbox sizing**: Sign-in terms of service checkbox sized to 1.25rem with flex label wrapping (#199). **Editable first/last names**: name parser strips middle initials from last name ("Aaron K. Clark" โ†’ First: "Aaron", Last: "Clark"); First Name and Last Name become first-class rows in Answer Bank, honored during fill-time profile refresh (#200). **Cookie consent dismissal**: automatic dismissal of OneTrust, Cookiebot, Zoho, Workable, and "Accept all" dialogs before Apply, on new tabs, and before form fill (#202). **Provider re-detection & closed postings**: ATS provider re-evaluated at submit time; HTTP 404/410 reported as "gone" rather than "no form found" (#203). **Rรฉsumรฉ attach verification**: failed attach treated as unmapped; submitter observes 2.5s for late `uploadFile` errors; sweeps `[role=group][aria-required]` wrapper elements; retries "Enter manually" (#195, #205). **Web component deep discovery (Zoho Recruit)**: inspects up to 12 ancestor levels for `label`, `data-label`, or row labels; detects required stars (`*`); disambiguates shared IDs; unlabelled questions and captchas never sent to LLM; social profiles mapped strictly (#207). - โœ… **Step 10 โ€” Pipeline view & "Apply later" trap prevention** (#210, #211, #212; v1.32.0): **Pipeline View**: clicking PIPELINE label in dashboard strip opens live submit queue drawer (`GET /api/pipeline`), evaluating worker gates in advance (phase, will do, reason, then_submit flag), Ready holding reasons, and "Submit all clean", auto-refreshing every 15 seconds. **"Apply later" trap prevention**: ignores lone email boxes as application forms; ignores buttons matching "later / send me the link / remind / subscribe / alert" across English, French, German, Spanish, and Portuguese; recognizes email sign-in gates vs clean dry runs. ## Backlog / ideas - โœ… **Security: keep instance LLM API keys bound to trusted endpoints** (issue #47) โ€” tenant `base_url` overrides currently inherit the instance `Llm__ApiKey` when the tenant does not store its own key. Change `EffectiveLlmConfig.Resolve` so an instance key is used only with the instance URL, or explicitly bind keys to an allowlisted origin. Also SSRF-check tenant LLM `base_url` before sending rรฉsumรฉ data to it. - โฌœ **Security: cap LLM response bodies** (issue #48) โ€” `OpenAiCompatibleLlmClient` reads success/error bodies as full strings. Switch to `ResponseHeadersRead`, enforce a content-length ceiling, and stream with a hard byte cap before JSON parsing or error-detail logging. - โœ… **Security: restrict forwarded-header trust** (issue #49) โ€” `UseForwardedHeaders` accepts `X-Forwarded-For` / `X-Forwarded-Proto` only from configured `KnownProxies` / `KnownIPNetworks`. - โœ… **Security/stability: add API request and field limits** (issue #50) โ€” global JSON body caps plus per-field/per-list ceilings for applications, criteria keywords/excludes/ATS boards, rรฉsumรฉ sections, links, and highlights. - โœ… **Security: make Python link-check SSRF guard connect by validated IP** (issue #51) โ€” the poller connects by validated IP address to close the DNS rebinding TOCTOU window. - โœ… **Scalability/stability: serialize tenant poll runs** (issue #52) โ€” per-tenant advisory lock serializes overlapping fast drain and scheduled poll passes. - โœ… **Deployment hardening: split dev compose from production defaults** (issue #53) โ€” hardened `docker-compose.production.yml` with non-root containers, no DB host port, `cap_drop`, and read-only root filesystems. - โœ… **Scalability: paginate/delta-refresh applications** (issue #54) โ€” `/api/apps` supports ETags / `If-None-Match` returning `304 Not Modified` for fast, low-overhead list validation. - โฌœ **`tailscale serve` front-end** โ€” rainy-day: serve over Tailscale instead of a self-signed TLS + reverse proxy (see the `plan.md` appendix).