No description
Find a file
namcho a1a8e834d3
Some checks failed
CI / static-analysis (push) Has been cancelled
CI / unit-integration (push) Has been cancelled
CI / mutation-testing (push) Has been cancelled
CI / e2e (push) Has been cancelled
docs: the band cannot name the commit that writes it — stop counting things that move
`5aea8b6` fixed a stale handoff band by pointing it at HEAD. That was an
over-correction, and measuring the convention afterwards showed it two ways:

  2026-09-08c names fb3cafa  (the R3 fix; wrap 25bf327 landed after)
  2026-09-08b names 902f4ba  (the DB fix;  wrap landed after)

Both name the last CODE commit and let the wrap follow. Naming HEAD instead
guarantees staleness by exactly one commit, because the commit that writes the
band necessarily post-dates it — which is precisely what happened: `5aea8b6`
made its own band wrong on landing.

The band now names the two backend fixes, says documentation commits follow,
and gives no count that changes. The prod-gap figure keeps its command so it
stays checkable rather than merely plausible.

The note in the band records that I over-corrected by ASSUMING the convention
instead of measuring it — the same mistake this session spent nine commits
finding in other people's work, so it would be dishonest to quietly re-edit it.
2026-09-08 04:46:33 +02:00
.agent_states feat: production hardening + admin panel + queue architecture 2026-06-17 00:39:38 +02:00
.claude docs: wrap 2026-09-03 — item 4 shipped, item 1 discharged, and a threshold that rejects real CVs 2026-09-03 02:20:37 +02:00
.forgejo/workflows fix(ci): the coverage path pointed one directory too deep, and failed 136 runs 2026-09-06 16:53:59 +02:00
.github/workflows wip: auth hardening, frontend modal fixes, CI workflows 2026-07-11 14:50:46 +02:00
.opencode/agents docs: the phantom module was live in four more tracked docs 2026-09-07 21:20:15 +02:00
backend fix(scrapers): the euro RANGE branch was unreachable, so every range lost its top 2026-09-08 01:41:14 +02:00
docker fix(docker): frontend image build was broken since 2026-07-23 — two legal docs were never copied into the build context 2026-08-13 13:00:01 +02:00
docs docs: AGENTS.md contradicted itself, and named the model its pin exists to avoid 2026-09-07 21:49:36 +02:00
frontend fix(jobs): two cache writes that misrepresented the board 2026-09-07 20:24:02 +02:00
scripts test(gate): 109 tests were excluded for a reason that was never true 2026-09-07 23:38:15 +02:00
.agents.local.md docs: the phantom module was live in four more tracked docs 2026-09-07 21:20:15 +02:00
.dockerignore fix(docker): frontend image build was broken since 2026-07-23 — two legal docs were never copied into the build context 2026-08-13 13:00:01 +02:00
.env.example feat: production hardening + admin panel + queue architecture 2026-06-17 00:39:38 +02:00
.env.production fix(llm): stop sending CVs to whichever provider OpenRouter happens to pick 2026-08-13 12:00:51 +02:00
.gitattributes feat(trust): version+SHA provenance — GET /api/v1/version + footer badge 2026-07-24 09:21:55 +02:00
.gitignore chore(git): ignore SQLite's WAL sidecars, which every test run leaves behind 2026-09-06 08:07:02 +02:00
.pre-commit-config.yaml fix(gates): two prettier versions disagreed, so one CSS file could satisfy neither 2026-09-06 17:19:32 +02:00
.project_rules.md docs: the phantom module was live in four more tracked docs 2026-09-07 21:20:15 +02:00
.wip-manifest.json feat(launch): Phase 6 — monitoring, secrets, TLS, zero-downtime 2026-06-20 14:33:05 +02:00
AGENTS.md docs: §13 — promote "measure what the FIX would do", earned three times this session 2026-09-08 03:16:48 +02:00
check_db.py fix: complete financial tab per-job isolation, add test pipeline 2026-04-29 21:46:15 +02:00
CLAUDE.md docs: the handoff prompt gets its own file, and it holds exactly one 2026-08-10 19:19:43 +02:00
DEPLOYMENT.md docs: rewrite DEPLOYMENT.md — it told you to break the deploy 2026-09-07 20:54:17 +02:00
future_improvements.md feat(scaffolding): initial project structure with backend, frontend, and agent configs 2026-04-19 13:51:45 +02:00
GIT_WORKFLOW.md docs: the phantom module was live in four more tracked docs 2026-09-07 21:20:15 +02:00
gotchas.md docs: §13 — promote "measure what the FIX would do", earned three times this session 2026-09-08 03:16:48 +02:00
LICENSE.md docs: add PolyForm Noncommercial 1.0.0 licence 2026-07-28 22:49:52 +02:00
NEXT_SESSION.md docs: 43(e) — a health check that cannot fail, and why fixing it would retire a source 2026-09-08 03:47:29 +02:00
package-lock.json feat(profile): add About Me, Requirements fields, MiniMax LLM config, interactive dashboard buttons 2026-04-19 16:18:08 +02:00
platforms.md feat(scaffolding): initial project structure with backend, frontend, and agent configs 2026-04-19 13:51:45 +02:00
RCA_TASK_STALL.md feat(ui): complete React job tracker matching Streamlit UI 2026-04-23 10:35:18 +02:00
README.md docs: AGENTS.md contradicted itself, and named the model its pin exists to avoid 2026-09-07 21:49:36 +02:00
requirements.md feat(scaffolding): initial project structure with backend, frontend, and agent configs 2026-04-19 13:51:45 +02:00
run_local.sh fix(auth): restore CSRF token on 'Continue with Code' login 2026-06-27 16:34:51 +02:00
SESSION_STATE.md docs: the band cannot name the commit that writes it — stop counting things that move 2026-09-08 04:46:33 +02:00
start_backend.sh feat: production hardening + admin panel + queue architecture 2026-06-17 00:39:38 +02:00

Job Hunter Platform

Multi-user job-hunting automation platform. Deployed on Raspberry Pi 5 (aarch64) behind Caddy with automatic TLS.


Architecture Overview

Secrets & Configuration — Two Sources

The system uses two separate mechanisms for configuration, intentionally:

Source Mechanism What goes there Priority
docker/secrets/*.txt Docker secrets — mounted into containers at /run/secrets/ API keys, HMAC key — anything secret Highest (runtime)
docker-compose.yml environment: section Plain env vars — visible in docker inspect LLM_PROVIDERS, NODE_ENV, URLs, port config — non-secret config Fallback

Why two sources?

  • Docker secrets are never exposed in docker inspect, logs, or compose dumps. They're securely mounted as tmpfs files.
  • Regular env vars are fine for non-sensitive config like LLM_PROVIDERS=minimax,openrouter or NODE_ENV=production.
  • .env.production at the project root is a template/documentation only — the system never reads it directly at runtime. It exists so you know what to set.

At startup, the backend scans /run/secrets/ and promotes every file into an environment variable (backend/src/api/main.py:_validate_env). So a file named minimax_api_key becomes MINIMAX_API_KEY in the application. This means all code reads from os.environ uniformly, regardless of whether the value came from a Docker secret or an env var.

HMAC_SECRET_KEY — What It Does

The root signing key for the entire platform (backend/src/api/auth.py, backend/src/api/admin/auth.py):

Use Where What it signs
Session code signing backend/src/api/auth.py:104 HMAC-SHA256 of each user's session code. Prevents session forgery.
Admin JWT secret backend/src/api/admin/auth.py:21 Signs all admin JWT tokens (if ADMIN_JWT_SECRET not set separately).

If you lose HMAC_SECRET_KEY, all user sessions and admin tokens become invalid.

Admin JWT — How It Works

Generated when an admin logs in via POST /api/v1/admin/auth/login:

def create_admin_token(admin_id, username, role):
    # Returns a JWT with: sub, username, role, iat, exp
    # Signed with HS256 (HMAC-SHA256) using ADMIN_JWT_SECRET

Stored client-side only — the admin browser keeps it in localStorage.

Sent on every admin API request as:

Authorization: Bearer <token>

Validated by the require_admin FastAPI dependency on every protected route.

Expiration: 24 hours by default, configurable via ADMIN_JWT_EXPIRY_HOURS env var.

Multiple admins: Yes — create additional accounts via POST /api/v1/admin/accounts (requires existing admin auth) or direct DB insert into the admin_accounts table. Each admin gets their own token on login.

LLM Provider Chain

Set via LLM_PROVIDERS=minimax,openrouter,ollama (comma-separated, in priority order). If the first provider fails (timeout, 5xx, network error), the chain auto-falls to the next.

Each provider reads {PREFIX}_API_KEY, {PREFIX}_BASE_URL, {PREFIX}_MODEL:

Provider in chain Env prefix Default model
minimax MINIMAX_* MiniMax-Text-01
openai OPENAI_* gpt-4o-mini
openrouter OPENROUTER_* anthropic/claude-3-haiku
ollama OLLAMA_* llama3.2
llamacpp LLAMACPP_* default
vllm VLLM_* default

Example — use MiniMax as primary, fall back to OpenRouter's GPT-4o:

LLM_PROVIDERS=minimax,openrouter
MINIMAX_MODEL=MiniMax-Text-01
OPENROUTER_MODEL=openai/gpt-4o

Edit these in docker-compose.yml environment: section or in .env.production (for reference).


Prerequisites (Target Machine)

  • Docker Engine 20.10+ & Docker Compose v2
  • Caddy (or another reverse proxy for TLS) — only if using the standard architecture
  • git to clone the repo
  • API keys for each provider in your chain

Quick Start — First Deployment

1. Clone & Prepare Secrets

git clone ssh://git@<your-git-server>:2222/namcho/job-hunter-public.git
cd job-hunter-public
mkdir -p docker/secrets

Create secret files (one per file, no trailing newline):

python3 -c "import secrets; print(secrets.token_hex(32))" > docker/secrets/hmac_secret_key.txt
python3 -c "import secrets; print(secrets.token_urlsafe(32))" > docker/secrets/cleanup_api_key.txt
echo -n "your-minimax-api-key" > docker/secrets/minimax_api_key.txt
echo -n "your-openrouter-api-key" > docker/secrets/openrouter_api_key.txt

2. Configure Provider Models (if not using defaults)

Edit docker-compose.yml and set models under the backend environment: section:

- OPENROUTER_MODEL=openai/gpt-4o
- MINIMAX_MODEL=MiniMax-Text-01

3. Build & Deploy

LLM_PROVIDERS=minimax,openrouter docker compose -f docker/docker-compose.yml build
LLM_PROVIDERS=minimax,openrouter docker compose -f docker/docker-compose.yml up -d

4. Configure Reverse Proxy (if using Caddy)

Add to /etc/caddy/Caddyfile:

jh.yourdomain.com {
    reverse_proxy localhost:8307
}
sudo caddy reload --config /etc/caddy/Caddyfile

5. Bootstrap Admin Account

# Run from the docker/ directory: compose auto-loads the host-local
# docker-compose.override.yml from the CWD only, and running this from the repo
# root silently drops CELERY_ENABLED / LLM_PROVIDERS (gotchas.md 2026-07-15).
cd docker && docker compose exec backend python -c "
from infrastructure.database.connection import get_db_context
from api.admin.auth import hash_admin_password, generate_default_password
import asyncio, uuid

async def create_admin():
    async with get_db_context() as db:
        pwd = generate_default_password()
        hashed = hash_admin_password(pwd)
        await db.execute(
            'INSERT INTO admin_accounts (id, username, password_hash, role, created_at, is_active) VALUES (?, ?, ?, ?, datetime(\"now\"), 1)',
            (str(uuid.uuid4()), 'admin', hashed, 'superadmin')
        )
        await db.commit()
        print(f'Admin user: admin')
        print(f'Password: {pwd}')

asyncio.run(create_admin())
"

Save the generated password — you won't see it again.

6. Verify

Check URL / Method
API health GET https://jh.yourdomain.com/health
Prometheus metrics GET https://jh.yourdomain.com/metrics
Admin login https://jh.yourdomain.com/admin/login
Health dashboard https://jh.yourdomain.com/admin/health
Rate limit header Check X-RateLimit-Remaining on any API response

Migration to Another Server

Step 1 — Database

The SQLite database lives in a Docker volume named sqlite_data. Find it on the source server:

docker volume inspect job-hunter_sqlite_data  # note the Mountpoint path
# Usually: /var/lib/docker/volumes/job-hunter_sqlite_data/_data/job_hunter.db

Copy the .db file to the target server:

scp user@old-server:/path/to/job_hunter.db ./job_hunter.db

On the target, before starting the stack, restore:

docker volume create job-hunter_sqlite_data
# Stop stack, copy db into volume, restart

Alternative (if backup container was running): check docker/backups/ for timestamped .db files:

ls docker/backups/
cp docker/backups/job_hunter_20250101_120000.db ./job_hunter.db

Step 2 — Secrets

Copy the docker/secrets/ directory exactly as-is:

rsync -av docker/secrets/ user@new-server:~/job-hunter-public/docker/secrets/

If HMAC_SECRET_KEY changes, all existing sessions and admin tokens are invalidated.

Step 3 — Images

Either rebuild on the target:

cd ~/job-hunter-public
docker compose -f docker/docker-compose.yml build

Or save/load the images:

# Source
docker save docker-backend docker-frontend docker-worker | gzip > images.tar.gz
scp images.tar.gz user@new-server:~/

# Target
docker load < images.tar.gz

Step 4 — Full checklist after migration

  • Secrets match source (especially HMAC_SECRET_KEY)
  • Database file copied and in place
  • Images built or loaded
  • Caddy config updated for the new host
  • LLM_PROVIDERS matches original
  • Provider API keys are valid
  • Admin can log in with existing credentials
  • /health returns {"status": "ok"}
  • /metrics returns valid Prometheus output

From-Scratch Setup (New Hardware)

1. OS + Docker

# Debian/Ubuntu / Raspberry Pi OS
sudo apt update && sudo apt install -y docker.io docker-compose-v2 git
sudo usermod -aG docker $USER
newgrp docker

2. Reverse Proxy (Caddy)

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update && sudo apt install caddy

3. Clone + Secrets + Deploy

Follow Quick Start steps 16 above.

4. Optional: Local LLM Fallback (Ollama)

docker run -d --restart unless-stopped --name ollama -p 11434:11434 ollama/ollama:latest
docker exec ollama ollama pull llama3.2  # or whatever model you want

The provider chain will automatically detect Ollama at host.docker.internal:11434/v1.


File Reference

File Purpose
docker/docker-compose.yml Service definitions, networks, volumes
docker/Dockerfile.backend FastAPI backend image
docker/Dockerfile.frontend React SPA + nginx image
docker/Dockerfile.worker Celery worker image
docker/nginx-frontend.conf Frontend container nginx config (proxies /api/ to backend)
docker/secrets/*.txt Docker secrets (API keys, HMAC key)
.env.production Template / documentation for all env vars and providers
backend/src/infrastructure/llm/llm_service.py LLM provider chain logic
backend/src/api/admin/auth.py Admin JWT + password auth
backend/src/api/auth.py User session codes + HMAC signing
backend/src/api/main.py App startup, env validation, Docker secrets loader

Licence

PolyForm Noncommercial License 1.0.0 — full text in LICENSE.md.

The source is public so you can read it, run it, and learn from it. It is not open source in the OSI sense, and calling it that would be wrong: the licence forbids commercial use.

You want to… Allowed?
Read and study the source Yes, freely
Run your own copy for your own job hunt Yes, freely
Modify it for your own personal use Yes, freely
Share a modified copy, free of charge, for others' personal use Yes
Use it at, or on behalf of, a company No — needs a paid licence
Sell it, or sell a product derived from it No — needs a paid licence
Run it as a paid or ad-supported service No — needs a paid licence

Charities, schools, public research bodies and government institutions count as noncommercial under the licence, whatever their funding.

For a commercial licence, contact the copyright holder.

Third-party dependencies

This licence covers this project's own source only. Every dependency in backend/requirements*.txt and frontend/package.json keeps its own licence, and several are permissive open-source licences that place no such restriction on you. Nothing here relicenses them.