{"id":50792851,"url":"https://github.com/cryptojones/osapplytrack","last_synced_at":"2026-06-12T12:02:24.267Z","repository":{"id":363195347,"uuid":"1261886234","full_name":"CryptoJones/OSApplyTrack","owner":"CryptoJones","description":"Open Source Job Search, Application, and Tracking Tool","archived":false,"fork":false,"pushed_at":"2026-06-07T20:49:18.000Z","size":1825,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-07T22:20:01.659Z","etag":null,"topics":["job-search","jobs","jobsearch","jobsearch-automation","jobseeker"],"latest_commit_sha":null,"homepage":"","language":"C#","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/CryptoJones.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-06-07T09:34:32.000Z","updated_at":"2026-06-07T20:49:22.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/CryptoJones/OSApplyTrack","commit_stats":null,"previous_names":["cryptojones/osapplytrack"],"tags_count":1,"template":false,"template_full_name":null,"purl":"pkg:github/CryptoJones/OSApplyTrack","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/CryptoJones%2FOSApplyTrack","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/CryptoJones%2FOSApplyTrack/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/CryptoJones%2FOSApplyTrack/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/CryptoJones%2FOSApplyTrack/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/CryptoJones","download_url":"https://codeload.github.com/CryptoJones/OSApplyTrack/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/CryptoJones%2FOSApplyTrack/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34243053,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-12T02:00:06.859Z","response_time":109,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["job-search","jobs","jobsearch","jobsearch-automation","jobseeker"],"created_at":"2026-06-12T12:01:45.874Z","updated_at":"2026-06-12T12:02:24.183Z","avatar_url":"https://github.com/CryptoJones.png","language":"C#","funding_links":[],"categories":[],"sub_categories":[],"readme":"# OSApplyTrack\n\n\u003e ### Your job hunt, self-hosted and on autopilot.\n\n[![CI](https://github.com/CryptoJones/OSApplyTrack/actions/workflows/ci.yml/badge.svg)](https://github.com/CryptoJones/OSApplyTrack/actions/workflows/ci.yml)\n[![Release](https://img.shields.io/github/v/release/CryptoJones/OSApplyTrack?logo=github\u0026sort=semver)](https://github.com/CryptoJones/OSApplyTrack/releases/latest)\n[![GHCR](https://img.shields.io/badge/ghcr.io-images-2496ED.svg?logo=docker\u0026logoColor=white)](https://github.com/CryptoJones?tab=packages\u0026repo_name=OSApplyTrack)\n[![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](./LICENSE)\n[![.NET 10](https://img.shields.io/badge/.NET-10-512BD4.svg?logo=dotnet\u0026logoColor=white)](https://dotnet.microsoft.com/)\n[![C#](https://img.shields.io/badge/C%23-239120.svg)](https://learn.microsoft.com/dotnet/csharp/)\n[![ASP.NET Core](https://img.shields.io/badge/ASP.NET_Core-512BD4.svg?logo=dotnet\u0026logoColor=white)](https://learn.microsoft.com/aspnet/core/)\n[![Python 3.12+](https://img.shields.io/badge/Python-3.12+-3776AB.svg?logo=python\u0026logoColor=white)](https://www.python.org/)\n[![PostgreSQL 17](https://img.shields.io/badge/PostgreSQL-17-4169E1.svg?logo=postgresql\u0026logoColor=white)](https://www.postgresql.org/)\n[![Docker](https://img.shields.io/badge/Docker-Compose-2496ED.svg?logo=docker\u0026logoColor=white)](https://docs.docker.com/compose/)\n[![JavaScript](https://img.shields.io/badge/JavaScript-vanilla-F7DF1E.svg?logo=javascript\u0026logoColor=black)](./api/ApplyTrack.Api/wwwroot)\n[![Multi-tenant](https://img.shields.io/badge/multi--tenant-yes-success.svg)](#data-model)\n[![Self-hosted](https://img.shields.io/badge/self--hosted-yes-success.svg)](#quickstart-docker)\n[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#contributing)\n\n**Open-source, multi-tenant, self-hostable job-application tracker.** Track every\napplication through its pipeline (lead → applied → screen → onsite → offer), keep\nper-user search criteria and a company blacklist, and let a background poller\ndiscover fresh remote roles from public job boards and stage them as leads — so\nyour pipeline refills itself while you sleep.\n\nRun it on your laptop with one `docker compose up`, or self-host it for your whole\njob search. Your data is yours: one-click export to a single JSON snapshot you can\nimport into any other instance, one-call account deletion, no lock-in, no\ntelemetry, no SaaS.\n\n\u003cp align=\"center\"\u003e\n  \u003cimg src=\"docs/screenshot.png\" alt=\"OSApplyTrack — the multi-lane pipeline dashboard with auto-discovered leads\" width=\"900\"\u003e\n\u003c/p\u003e\n\n---\n\n## Table of contents\n\n- [Why OSApplyTrack](#why-osapplytrack)\n- [Architecture](#architecture)\n- [Quickstart (Docker)](#quickstart-docker)\n- [How it works](#how-it-works)\n- [Configuration](#configuration)\n- [API reference](#api-reference)\n- [Data model](#data-model)\n- [The discovery poller](#the-discovery-poller)\n- [Cover letters](#cover-letters)\n- [Security \u0026 hardening](#security--hardening)\n- [Your data](#your-data)\n- [First-run import](#first-run-import-optional)\n- [Local development](#local-development)\n- [Tests](#tests)\n- [Project layout](#project-layout)\n- [Roadmap](#roadmap)\n- [Contributing](#contributing)\n- [License](#license)\n\n---\n\n## Why OSApplyTrack\n\n- **It tracks the whole funnel.** Every application is a row with a status lane,\n  company, role, link, location, salary, source, contacts, applied/follow-up dates,\n  a relevance score, and free-form Markdown notes.\n- **It finds work for you.** A Python poller fetches listings from public job\n  boards, scores them against your saved criteria, drops anything from a\n  blacklisted company, dedupes against what you've already seen, and stages the\n  survivors as fresh leads.\n- **It drafts your cover letters.** Point it at any OpenAI-compatible LLM — a local\n  Ollama/vLLM model (so your résumé never leaves the box, $0 per draft) or a hosted\n  provider — and generate a letter per application, tailored from your structured\n  résumé. Keys are yours, encrypted at rest.\n- **It's genuinely multi-tenant.** Every row is owned by a tenant; every query in\n  both runtimes unconditionally filters `WHERE tenant_id`. One deployment cleanly\n  serves many users with hard data isolation.\n- **It's yours to keep.** Export your whole account as one JSON snapshot and import\n  it into another instance any time — applications, criteria, and blacklist travel\n  together, so you're never locked in. Delete your account and every row it owns\n  cascades away in a single statement.\n- **It's a single-binary-feeling deploy.** Postgres + a .NET API that also serves\n  the SPA + a Python cron worker — three containers, one `docker compose up`.\n\n## Architecture\n\nA polyglot backend behind one dependency-free vanilla-JS single-page app:\n\n- **API — ASP.NET Core (.NET 10):** magic-link auth, opaque server-side sessions,\n  CRUD, and it serves the SPA. Dapper + Npgsql over Postgres; DbUp migrations run\n  on startup. Minimal APIs on Kestrel.\n- **Poller — Python:** a cron worker that fetches and scores job listings and\n  writes new leads. Reuses the original `applytrack` fetchers (`httpx` + `psycopg3`).\n- **Postgres:** the two runtimes never call each other — **the database schema is\n  the contract.** The .NET API owns auth/sessions + CRUD and migrates the schema;\n  the poller writes leads and reads profiles/seen/users. Both filter `tenant_id`.\n\n```\n            ┌─────────────────────────────────┐\nBrowser ──► │ ASP.NET Core (.NET 10, Kestrel)  │\n (the SPA)  │  • serves the SPA + JSON API     │──┐\n            │  • magic-link auth + sessions    │  │\n            │  • CRUD + criteria + blacklist   │  │\n            └─────────────────────────────────┘  ├──► Postgres  (shared schema\n            ┌─────────────────────────────────┐  │              = the contract)\n Cron  ───► │ Python poller                    │──┘\n            │  • fetch + score + dedupe leads  │\n            │  • drain the on-demand poll queue│\n            └─────────────────────────────────┘\n```\n\nThe decoupling is deliberate: the API can answer \"Poll now\" instantly by enqueuing\na request, while the poller drains that queue out of band. Neither runtime blocks\non the other; the only thing they share is the database.\n\n## Quickstart (Docker)\n\n```sh\ncp .env.example .env        # optional: edit the Postgres credentials / API port\ndocker compose up --build   # brings up db + api + poller\n```\n\nOpen **http://localhost:8080**.\n\n\u003e **Port guard rail.** 8080 is a contested port (vLLM, llama.cpp, and plenty of\n\u003e dev tools default to it), so the stack **checks before it binds**: a compose\n\u003e preflight refuses to start the api when anything already listens on\n\u003e `API_PORT`, with a clear error instead of silently winning the bind race.\n\u003e Relocate by setting `API_PORT` in `.env`. The\n\u003e [quadlet units](deploy/quadlet/) ship the same guard via `ExecStartPre`.\n\nPrefer prebuilt images? Each release publishes both runtimes to the GitHub\nContainer Registry, so you can skip the local build:\n\n```sh\ndocker pull ghcr.io/cryptojones/osapplytrack-api:latest\ndocker pull ghcr.io/cryptojones/osapplytrack-poller:latest\n```\n\nTo sign in, enter your email. In the default configuration the magic link is\n**printed to the API logs** instead of being mailed (zero email setup needed):\n\n```sh\ndocker compose logs api | grep magic-link\n```\n\nOpen that link and you're in. The first account created is tenant `1`.\n\n\u003e **Tip:** the poller is the third service (`poller`). `docker compose up` starts\n\u003e all three; if you only bring up `db` + `api`, no leads will ever be discovered\n\u003e because nothing drains the queue or runs the scheduled poll.\n\n## How it works\n\n**Sign-in (magic link).** `POST /api/auth/request` always returns `200 {ok:true}`\n— whether or not the address exists — so the surface can't be used to enumerate\naccounts. Behind that uniform response, a known/valid address gets a single-use,\n15-minute token (only its SHA-256 is stored). `GET /api/auth/verify` consumes the\ntoken, mints a 30-day **server-side** session (not a JWT — so logout is instant\nrevocation), sets an `HttpOnly` cookie, and redirects to `/` so the token leaves\nthe URL and browser history.\n\n**The tenancy choke-point.** A middleware resolves the session cookie to a\n`TenantContext` and is the only thing that lets `/api/*` through. Repositories are\ninjected from DI already scoped to the caller's tenant, so endpoint code physically\ncan't query another tenant's rows.\n\n**Optimistic concurrency.** Each application carries a `version`. Writes accept\n`?expected_version=` and answer **409 Conflict** on a mismatch, driving the SPA's\noverwrite-confirm flow — two tabs can't silently clobber each other.\n\n**Discovery.** The poller fetches sources once per pass, scores each listing\nagainst the tenant's criteria, drops blacklisted companies, dedupes against the\n`seen` ledger, and inserts the rest as `lead`-status applications.\n\n**Autofill.** When entering a lead by hand, paste the posting link and hit\n**⤓ Autofill**: the server fetches the page (`POST /api/scrape` — SSRF-guarded,\nrate-limited) and fills the still-empty fields from the page's\nschema.org `JobPosting` JSON-LD, falling back to OpenGraph/`\u003ctitle\u003e` heuristics.\nFields you already typed are never overwritten.\n\n## Configuration\n\nAll configuration is environment variables (see [`.env.example`](./.env.example)):\n\n| Variable | Default | Purpose |\n| --- | --- | --- |\n| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | `applytrack` | Postgres credentials, shared by `db`, `api`, and `poller`. |\n| `API_PORT` | `8080` | Host port the API publishes (the container always listens on 8080). |\n| `DRAIN_INTERVAL` | `60` | Seconds between drains of the on-demand poll queue (the SPA's \"Poll now\" button). |\n| `POLL_INTERVAL` | `3600` | Seconds between full multi-tenant polls. |\n| `ConnectionStrings__Postgres` | _(compose default)_ | Override to point the API at an external Postgres. |\n| `DATABASE_URL` | _(compose default)_ | Override to point the poller at an external Postgres (libpq URL). |\n| `APPLYTRACK_DIR` | `./applications` | Default folder the `import-md` command reads when `--dir` is omitted. |\n| `Llm__BaseUrl` / `Llm__Model` / `Llm__ApiKey` | _(empty)_ | Instance-default cover-letter LLM — any OpenAI-compatible endpoint (a local Ollama/vLLM/LM Studio model or a hosted provider). `ApiKey` is blank for a keyless local model. Each tenant can override these in the UI. See [Cover letters](#cover-letters). |\n| `APPLYTRACK_SECRETS_KEY` | _(empty)_ | Master key (AES-256-GCM) that encrypts each tenant's **own** stored LLM API key at rest. Leave unset to disable per-tenant keys — the instance default above is still used. |\n\n## API reference\n\nAll `/api/*` routes except the auth handshake require a valid session cookie;\nunauthenticated calls get **401** with a `{\"detail\": \"...\"}` body. `/health` is\nopen. Error bodies are uniform `{\"detail\": \"...\"}` across 400/404/409/500.\n\n### Auth\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `POST` | `/api/auth/request` | Body `{email}`. Always `200 {ok:true}` (no account enumeration). Per-IP rate-limited. |\n| `GET`  | `/api/auth/verify?token=…` | Consumes a single-use token, sets the session cookie, 302 → `/`. |\n| `POST` | `/api/auth/logout` | Drops the session row (instant revocation) and clears the cookie. |\n| `GET`  | `/api/auth/me` | `{email}` for the current session, else 401. |\n\n### Applications\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `GET`    | `/api/apps` | List the tenant's applications. |\n| `GET`    | `/api/stats` | Counts by `{status, lane}`. |\n| `GET`    | `/api/apps/{name}` | One application: `{filename, raw, fields, version, material}`. |\n| `POST`   | `/api/apps` | Create from structured fields → `201 {filename}`. |\n| `PUT`    | `/api/apps/{name}?expected_version=…` | Update structured fields (409 on version mismatch). |\n| `PUT`    | `/api/apps/{name}/raw?expected_version=…` | Replace the full Markdown document. |\n| `DELETE` | `/api/apps/{name}` | Delete → `204`. |\n| `POST`   | `/api/apps/{name}/draft` | Draft a tailored cover letter via the configured LLM; saves it and returns `{ok, material}`. Rate-limited. |\n| `POST`   | `/api/poll` | Enqueue an on-demand poll → `{count:0}`. Rate-limited; the worker drains it. |\n| `POST`   | `/api/scrape` | Body `{url}`. Fetch a posting page server-side (SSRF-guarded) and extract `{company, role, location, salary, source, description}` for the editor's Autofill. Rate-limited; 502 when the page can't be read. |\n\n### Criteria \u0026 blacklist\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `GET`    | `/api/criteria` | The tenant's discovery criteria (defaults when unset). |\n| `PUT`    | `/api/criteria` | Normalize + store posted criteria (junk dropped, score clamped). |\n| `GET`    | `/api/blacklist` | List blacklisted companies. |\n| `POST`   | `/api/blacklist` | Add a company; flips its open leads to `passed`. |\n| `POST`   | `/api/apps/{name}/blacklist` | Blacklist the company on a given application. |\n| `DELETE` | `/api/blacklist/{company}` | Remove a company. |\n\n### Account\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `GET`    | `/api/account/export` | One JSON snapshot: every application + criteria + blacklist. |\n| `GET`    | `/api/account/export/shared` | Anonymized opportunity list for a peer (`format: applytrack-shared`): slug, company, role, link, location, source — **no personal state**. |\n| `POST`   | `/api/account/import` | Load a snapshot (upsert by slug, one transaction) — or a shared list: every entry lands as a fresh `lead`, slugs you already track are skipped. |\n| `DELETE` | `/api/account` | Delete the account; every owned row cascades away. |\n\n### Materials (cover letters)\n\n| Method | Path | Notes |\n| --- | --- | --- |\n| `GET`    | `/api/resume` | The tenant's structured résumé — the only facts the drafter may assert. |\n| `PUT`    | `/api/resume` | Normalize + store the résumé (dedupes skills, drops empty rows/links). |\n| `GET`    | `/api/llm-settings` | The tenant's endpoint override + the instance default. The API key is **write-only** — never returned, only a `has_api_key` flag. |\n| `PUT`    | `/api/llm-settings` | Set `base_url` / `model` / `api_key` (omit `api_key` to leave it untouched, blank to clear it) and `cover_letters_enabled` (omit to keep; `false` disables all drafting for the tenant). |\n| `DELETE` | `/api/apps/{name}/cover-letter` | Discard a generated letter → `204`. |\n\n### Not in v1\n\n`GET /api/apps/{name}/check-link` answers **501** with a `{detail}` body the SPA\nsurfaces as a clean toast (see [Roadmap](#roadmap)). Cover-letter drafting\n(`POST /api/apps/{name}/draft`) is implemented — see [Cover letters](#cover-letters).\n\n## Data model\n\nThe schema is migrated by **DbUp** from idempotent `.sql` scripts under\n`api/ApplyTrack.Api/Migrations/`, run automatically on API startup:\n\n| Table | Holds |\n| --- | --- |\n| `users` | Accounts. A user's `id` **is** its `tenant_id` (tenants are users). |\n| `applications` | The tracked applications. `UNIQUE (tenant_id, name)`; `version` for optimistic locking. |\n| `search_profiles` | Per-tenant discovery criteria the poller reads. |\n| `blacklist` | Per-tenant blocked companies. |\n| `magic_tokens` | SHA-256 of issued login tokens, with expiry. Single-use. |\n| `sessions` | Opaque server-side sessions (instant revocation on logout). |\n| `seen` | The dedupe ledger — listings already surfaced, so leads don't repeat. |\n| `poll_requests` | The on-demand \"Poll now\" queue the worker drains. |\n| `resume_profiles` | Per-tenant structured résumé — the facts the cover-letter drafter feeds the LLM. |\n| `llm_settings` | Per-tenant LLM endpoint override; a tenant's own API key is stored **AES-256-GCM-encrypted** at rest. |\n| `cover_letters` | Generated cover letters, one per application (`FK → applications ON DELETE CASCADE`). |\n\nAccount deletion relies on `ON DELETE CASCADE` foreign keys (migrations\n`0005`/`0006`/`0009`): one `DELETE FROM users` removes every dependent row.\n\n## The discovery poller\n\nThe poller is a single container running two loops with no cron daemon\n(see [`docker/poller-entrypoint.sh`](./docker/poller-entrypoint.sh)):\n\n- **Fast lane** — drains the on-demand queue every `DRAIN_INTERVAL` seconds, so the\n  \"Poll now\" button doesn't wait for the hourly pass.\n- **Slow lane** — a full multi-tenant poll every `POLL_INTERVAL` seconds.\n\nA transient board/DB failure can't kill either loop; the next tick retries. Prefer\nhost cron or a systemd timer? Run the CLI directly and drop the service:\n\n| Command | What it does |\n| --- | --- |\n| `applytrack poll` | Full poll across every active tenant (the hourly cron). |\n| `applytrack poll --drain` | Service only the on-demand poll queue (the fast cron). |\n| `applytrack poll --tenant \u003cid\u003e` | Poll a single tenant. |\n| `applytrack poll --limit \u003cn\u003e` | Cap results scanned per source (default 40). |\n| `applytrack import-md --dir \u003cpath\u003e --tenant \u003cid\u003e` | One-shot Markdown import. |\n\nEach accepts `--database-url` (a libpq URL), falling back to `DATABASE_URL` / the\n`POSTGRES_*` env vars.\n\n## Cover letters\n\nOSApplyTrack drafts a tailored cover letter per application from a structured\nrésumé you control — provider-agnostic, and built so your data can stay on-prem.\n\n\u003e **⚠ Any LLM — or none at all.** The backend is hard-required to work with **any\n\u003e OpenAI-compatible endpoint**; no vendor is baked in. And the whole engine is\n\u003e **optional**: untick *Enable cover-letter drafting* in **Settings · AI** and the\n\u003e app hides every drafting affordance and never calls a model for your account.\n\n- **Bring your own model.** The drafter calls an OpenAI-compatible\n  `POST {base_url}/chat/completions`, so the same code points at a free local model\n  (Ollama, vLLM, LM Studio) or any hosted provider (OpenAI, OpenRouter, Together,\n  Groq, …). A local model means **$0 per draft** and the résumé never leaves the box.\n- **Operator default + per-tenant override.** The instance sets a default endpoint\n  via `Llm__BaseUrl` / `Llm__Model` / `Llm__ApiKey`; each tenant can override any\n  field in the **Settings · AI** tab (override just the model, keep the URL, etc.).\n- **Your résumé is the only source of truth.** The **Settings · Résumé** tab captures name,\n  headline, summary, experience, skills, certifications, and links — the LLM is told\n  these are the *only* facts it may assert, so it can't invent employers or metrics.\n- **Keys encrypted at rest.** A tenant's own API key is write-only: sealed with\n  AES-256-GCM under `APPLYTRACK_SECRETS_KEY` and never echoed back. Without that\n  master key the per-tenant-key path is disabled (the instance default still works).\n- **Generate from the application sheet.** Each app gets a **Generate cover letter**\n  action; the result renders inline with copy / download `.md` / regenerate /\n  discard. Letters are stored per application and are excluded from the export\n  snapshot by design.\n\n## Security \u0026 hardening\n\nOSApplyTrack is built to face the public internet behind a reverse proxy:\n\n- **No account enumeration.** `POST /api/auth/request` returns an identical `200`\n  for known, unknown, and malformed addresses.\n- **Single-use, short-lived tokens.** Login tokens are 15-minute, one-shot, and\n  stored only as SHA-256. Sessions are opaque and server-side, so logout revokes\n  instantly (no stranded JWTs).\n- **Hard tenant isolation.** Repositories are DI-scoped per tenant; every query\n  filters `tenant_id`. There is no endpoint path that reads across tenants.\n- **Strict security headers on every response** (custom middleware): a tight\n  `Content-Security-Policy` (`script-src 'self'`, no inline scripts),\n  `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`,\n  `Referrer-Policy: no-referrer`, and HSTS once the request is HTTPS.\n- **Output sanitization.** User Markdown is rendered with `marked` and scrubbed\n  through **DOMPurify** before it touches the DOM — defense in depth against stored\n  XSS even though the poller already strips HTML at ingestion.\n- **Encrypted secrets at rest.** A tenant's own LLM API key is sealed with\n  **AES-256-GCM** under an operator master key (`APPLYTRACK_SECRETS_KEY`) before it\n  reaches the database, and is never returned by the API — only a `has_api_key`\n  flag. With no master key configured, the per-tenant-key path is disabled rather\n  than storing anything in the clear.\n- **Rate limiting.** The magic-link and poll endpoints are per-IP fixed-window\n  rate-limited so the always-200 auth surface can't be abused for spam or probing.\n- **SSRF-hardened link probing.** The link prober refuses to connect to\n  private/loopback/link-local/reserved addresses and re-checks every redirect hop,\n  so a hostile listing URL can't pivot into your network.\n- **Behind HTTPS.** Front the API with a TLS-terminating reverse proxy (Caddy,\n  nginx, or `tailscale serve`). The API honors `X-Forwarded-Proto`, so the session\n  cookie's `Secure` flag is set automatically. Don't expose Kestrel directly.\n- **Change the default password.** For any deployment reachable beyond `localhost`,\n  change `POSTGRES_PASSWORD` (and the matching connection string) from the\n  `applytrack` default before first boot — the bundled value is local-dev only.\n- **Dependency CVE watch.** [`.forgejo/workflows/audit.yml`](./.forgejo/workflows/audit.yml)\n  runs `dotnet list package --vulnerable --include-transitive` and `pip-audit` on\n  every push/PR and weekly, failing the build on a known-vulnerable dependency. Run\n  the same two commands locally any time.\n\n## Your data\n\n- **Export** — `GET /api/account/export` returns a single JSON snapshot of your\n  whole account: every application (all fields + its slug, so apply links survive a\n  move), your search criteria, and your company blacklist. A real backup, and the\n  door's never locked.\n- **Import** — `POST /api/account/import` loads a snapshot back. Applications\n  upsert by slug (an incoming app overwrites a matching local one, new slugs are\n  added, untouched apps stay), so re-importing is idempotent. The whole load runs in\n  one transaction — a mid-import failure leaves your account untouched. Use it to\n  migrate from one instance to another: export here, import there.\n- **Share** — `GET /api/account/export/shared` exports a peer-shareable\n  *opportunity list*: only the facts of each posting (company, role, link,\n  location, source, plus the slug for de-dup). Status, notes, contacts, dates,\n  score, and salary are stripped at the source. A peer imports the file and every\n  entry lands as a fresh `lead`; anything they already track is skipped, never\n  overwritten. All three live in **Settings · Account**.\n- **Delete** — `DELETE /api/account` removes your account and, via\n  `ON DELETE CASCADE`, every row that belongs to it (applications, search profile,\n  blacklist, seen ledger, queued polls, sessions, tokens) in one statement.\n\n## First-run import (optional)\n\nIf you're coming from the original single-user `applytrack`, import your existing\nMarkdown applications. **Sign in first** — `tenant_id` is a real foreign key to your\nuser account (so deleting the account cascades cleanly), which means a tenant must\nexist before any data is written under it. Then point the importer at your\n`applications/` folder and **your** tenant id (find it in the API logs or the\n`users` table — it's not necessarily `1` if other accounts exist):\n\n```sh\ndocker compose run --rm \\\n  -v \"$PWD/applications:/data\" \\\n  --entrypoint applytrack \\\n  poller import-md --dir /data --tenant \u003cyour-tenant-id\u003e\n```\n\n## Local development\n\nRun Postgres in a container and the two runtimes on the host:\n\n```sh\ndocker compose up -d db\n\n# API (reads appsettings.json → localhost Postgres)\ncd api \u0026\u0026 dotnet run --project ApplyTrack.Api\n\n# Poller (one-shot poll; needs DATABASE_URL or the POSTGRES_* / PG* env vars)\npip install -e '.[dev]'\nDATABASE_URL=postgresql://applytrack:applytrack@localhost:5432/applytrack applytrack poll\n```\n\nThe API listens on **http://localhost:5049** (per `launchSettings.json`), runs the\nDbUp migrations on startup, and serves the SPA — so that one URL is the whole app.\nIn Development with no SMTP configured, the magic-link login URL is printed to the\n**console** running `dotnet run`; click it to sign in.\n\n**Enable cover-letter drafting (optional).** Drafting stays off until an\nOpenAI-compatible endpoint is set. To turn it on for local testing, point the API at\na local model — e.g. [Ollama](https://ollama.com) (`ollama serve`, then\n`ollama pull llama3.1:8b`):\n\n```sh\ncd api\nLlm__BaseUrl=http://localhost:11434/v1 Llm__Model=llama3.1:8b \\\n  dotnet run --project ApplyTrack.Api\n```\n\nA hosted provider works the same way — add `Llm__ApiKey=…` (and set\n`APPLYTRACK_SECRETS_KEY` if tenants will store their own keys). Or skip the env\nentirely and configure it per-tenant in **Settings · AI**. See\n[Cover letters](#cover-letters).\n\n## Tests\n\n```sh\n# .NET — xUnit + Testcontainers (needs a running Docker daemon)\ncd api \u0026\u0026 dotnet test\n\n# Python — pytest (offline; no DB/network), plus lint + types\npytest\nruff check .\nmypy src\n```\n\nThe .NET suite drives the live HTTP stack with `WebApplicationFactory` against a\nthrowaway Postgres (Testcontainers), including the auth spine and cross-tenant\nisolation. The Python suite is fully offline (fakes for the DB and HTTP transport).\n\n## Project layout\n\n```\napi/                      the .NET solution\n  ApplyTrack.Api/         Minimal API host\n    Endpoints/            auth, apps, criteria, blacklist, account\n    Middleware/           tenancy choke-point, security headers, error mapping\n    Migrations/           DbUp .sql scripts (the schema = the contract)\n    wwwroot/              the vanilla-JS SPA (served by the API)\n  ApplyTrack.Api.Tests/   xUnit + Testcontainers\nsrc/applytrack/           the Python poller + CLI\ndocker/                   poller entrypoint (two-cadence loop)\ndocker-compose.yml        db + api + poller\nDockerfile.poller         the poller image\n```\n\n## Roadmap\n\nv1 is intentionally focused. Deferred, with clean seams already in place:\n\n- **Real email delivery.** The default `IEmailSender` writes the magic link to the\n  console; swap in an SMTP/HTTP sender behind the interface for a real deployment.\n- **Link checking.** `/api/apps/{name}/check-link` returns 501 today; the\n  SSRF-hardened prober already exists in the poller for when it's enabled.\n- **Richer cover-letter output.** The materials engine ships plain-text/Markdown\n  letters ([Cover letters](#cover-letters)); LaTeX/PDF rendering is the next module.\n\n## Contributing\n\nIssues and PRs welcome. Please keep the cross-runtime contract intact (every query\nfilters `tenant_id`), add tests for new behavior, and keep the SPA dependency-free.\nBoth test suites and the dependency audit run in CI.\n\n## License\n\n[Apache-2.0](./LICENSE). Copyright 2026 Aaron K. Clark.\n\nProudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcryptojones%2Fosapplytrack","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcryptojones%2Fosapplytrack","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcryptojones%2Fosapplytrack/lists"}