# The Stitching Beehive — Remediation TODO & Roadmap
Read this first:
- **Part A** is the ledger of the original remediation (TODO-01 … TODO-21): what's done,
and what's still open or was re-opened. The full original specs live in git history
(`git show 5761435:TODO.md`); the numbers are kept because `LEARNING-GUIDE.md` refers
to them.
- **Part B** is the active roadmap. Work the phases in order; items list dependencies.
Every concrete item has a **Done when** section — do not mark an item complete until
every check passes.
- Items marked **[HUMAN]** must be done by a person, not an agent.
- Test commands assume the repo root as working directory. The app listens on
`http://localhost:5000` with `dotnet run`; production runs via `docker compose up -d`
on a Linux VPS (see `README.md`).
- `LEARNING-GUIDE.md` explains the *why* — Part 1 for TODO-01…21, Part 2 (P1…P6) for the
lessons behind several items below.
---
# Part A — Original remediation: status ledger
| Item | Summary | Status |
|---|---|---|
| TODO-01 | Fix duplicate response-header crash in production | ✅ Done |
| TODO-02 | Stop deleting the database on every startup | ✅ Done |
| TODO-03 | Replace hand-rolled admin auth with cookie authentication | ✅ Done |
| TODO-04 | Move secrets out of `appsettings.json` | ⚠️ Done, then **regressed** — admin password was re-committed (see TODO-23) |
| TODO-05 | Replace regex HTML sanitizer with HtmlSanitizer library | ✅ Done |
| TODO-06 | Delete unused static website templates | ✅ Done |
| TODO-07 | Stop tracking generated binaries in git | ✅ Done |
| TODO-08 | Remove dead code | ✅ Done |
| TODO-09 | Port ImageService off System.Drawing to ImageSharp | ✅ Done |
| TODO-10 | Upgrade to .NET 10 | ⚠️ Done, then **downgraded to .NET 9** for Visual Studio compatibility; .NET 9 left support May 2026 (see TODO-27) |
| TODO-11 | Generate and use gallery thumbnails | ✅ Done |
| TODO-12 | Introduce EF Core migrations | ✅ Done |
| TODO-13 | Prune fake seed data | ✅ Done |
| TODO-14 | Store tip/service content as HTML in the database | ✅ Done |
| TODO-15 | Replace loose `.txt` site-content files with a database table | ✅ Done |
| TODO-16 | Small schema hardening | ✅ Done |
| TODO-17 | Move mutable data out of the deploy directory | ✅ Done |
| TODO-18 | Vendor frontend libraries locally, unify Bootstrap | ✅ Done (a leftover csproj exclusion broke publishing; fixed — see Learning Guide P1) |
| TODO-19 | Add analyzers and `.editorconfig` | ✅ Done |
| TODO-20 | Add GitHub Actions CI workflow | ✅ Done (gaps remain — see TODO-28) |
| TODO-21 | Multi-stage Dockerfile + fixed docker-compose | ✅ Done, variant: `Dockerfile.linux` is production; `Dockerfile` is VS's Windows-container file for local debugging; `docker-compose.dev.yml` added |
| TODO-H1 | Rotate the leaked credentials | ❌ **Still open** — see TODO-22 |
| TODO-H2 | Decide on git-history scrubbing | ❌ Still open (decide after TODO-22) |
---
# Part B — Active roadmap
## Phase 8 — Security hardening (launch blockers)
Everything in this phase should be finished **before** the site is exposed to the
internet, except where noted.
### TODO-22: Rotate the leaked credentials [HUMAN]
**Depends on:** none — do ASAP.
The Gmail app password (account `djterry17@gmail.com`) and the admin password
`AdminDebbie2025` were committed to git and remain readable in history. The same admin
password was re-committed in June 2026, so it is still the live value. A person must:
1. Revoke the old Gmail app password in the Google account; generate a new one.
2. Choose a **new** admin password (not `AdminDebbie2025` or a variant).
3. Supply both only via the server's `.env` file (see `README.md`) and, locally, via
`dotnet user-secrets`. Never commit them.
**Done when:** the old Gmail app password no longer authenticates, logging in with
`AdminDebbie2025` fails on the deployed site, and `git diff` shows no new secrets.
### TODO-23: Re-blank the admin password in appsettings.json
**Files:** `appsettings.json`
**Depends on:** TODO-22 (rotate first, or this just gets reverted again).
**Do:** Set `AdminSettings:Password` to `""`. The login code already fails closed on an
empty configured password ("Admin password is not configured"), and production supplies
the real value via `ADMIN_PASSWORD` in `.env`. For local dev:
`dotnet user-secrets set "AdminSettings:Password" "..."`.
**Done when:**
- `grep -n '"Password"' appsettings.json` shows only empty strings.
- `dotnet run` + login with the user-secrets password reaches `/Admin/Dashboard`.
- `ADMIN_PASSWORD=x docker compose config` still resolves (env path unchanged).
### TODO-24: Trust the reverse proxy; mark cookies Secure
**Files:** `Program.cs`
**Depends on:** none
**Why (one line):** behind Caddy the app sees plain HTTP from localhost, so it never knows
requests were HTTPS — auth/session cookies aren't marked `Secure`, and any
scheme-dependent logic (HSTS, redirect URLs, logged client IPs) is wrong.
**Do:**
1. Add `UseForwardedHeaders` (before auth/anything scheme-aware) handling
`XForwardedFor | XForwardedProto`. Caddy runs on the same host, and loopback is a
trusted proxy by default, so no extra network config is needed.
2. Set `Cookie.SecurePolicy = CookieSecurePolicy.Always` on the auth cookie and the
session cookie (these are only ever used by the admin, over HTTPS in production).
3. Leave HSTS to Caddy or keep `UseHsts()` — with forwarded headers it now actually fires.
**Done when:**
- `curl -sI -H "X-Forwarded-Proto: https" http://localhost:5000/Admin/Login` →
the `Set-Cookie` headers include `secure`.
- Local `dotnet run` (Development) still works over plain http.
### TODO-25: Rate-limit the login and contact endpoints
**Files:** `Program.cs`, `Controllers/AdminController.cs`, `Controllers/HomeController.cs`
**Depends on:** TODO-24 (so the limiter keys on the real client IP, not Caddy's).
**Why (one line):** the admin area is a single shared password — without throttling it is
an open invitation to brute force; the contact form is the other obvious abuse target.
**Do:** Use the built-in rate limiter (`AddRateLimiter` + `app.UseRateLimiter()`):
a fixed-window policy of ~5 requests/minute per client IP applied with
`[EnableRateLimiting("strict")]` to the Login POST and Contact POST actions. Return 429.
**Done when:**
- `for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:5000/Admin/Login -d "password=x"; done`
shows 429s after the first few attempts.
- Normal page browsing is unaffected (no limiter on GETs).
### TODO-26: Add a Content-Security-Policy header
**Files:** `Program.cs`
**Depends on:** none (easier now that all assets are local — TODO-18).
**Do:** Add CSP alongside the existing security headers. Start with:
`default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; frame-ancestors 'none'`
then browse every public and admin page with the browser console open and tighten/loosen
based on actual violations (inline scripts may need fixing or a nonce). If anything is
unclear, ship it as `Content-Security-Policy-Report-Only` first and promote after a week.
**Done when:**
- All pages render with zero CSP violations in the browser console.
- `curl -sI http://localhost:5000/ | grep -i content-security-policy` shows the policy.
### TODO-27: Upgrade to .NET 10 LTS [partly HUMAN]
**Files:** `The Stitching Beehive.csproj`, `Dockerfile`, `Dockerfile.linux`, `.github/workflows/ci.yml`, `dotnet-tools.json`
**Depends on:** [HUMAN] update Visual Studio to a version that supports .NET 10 — this is
the step that caused the downgrade last time (Learning Guide P4).
**Why (one line):** .NET 9 (STS) left support in May 2026; it runs fine but no longer
receives security patches, which is not acceptable for an internet-facing site for long.
**Do:** Bump `TargetFramework` to `net10.0`, base images to `:10.0`, CI `dotnet-version`
to `10.0.x`, and refresh `Microsoft.EntityFrameworkCore.*` to the matching major version.
**Done when:**
- CI is green and `docker compose build` succeeds on Linux.
- `docker compose up -d` serves `/` with 200 and an existing data volume migrates cleanly.
## Phase 9 — Operational hardening
### TODO-28: Make CI catch what actually broke this project
**Files:** `.github/workflows/ci.yml`
**Depends on:** none
**Why (one line):** the wwwroot/lib publish bug (Learning Guide P1) sailed through CI
because CI builds but never publishes; the vulnerable-package step also never fails
(`dotnet list package --vulnerable` exits 0 even when it finds vulnerabilities).
**Do:**
1. Add a publish step: `dotnet publish -c Release -o /tmp/publish` followed by an
assertion, e.g. `test -f /tmp/publish/wwwroot/lib/bootstrap/css/bootstrap.min.css`
and `test -d /tmp/publish/wwwroot/css`.
2. Make the vulnerability check fail the build:
`dotnet list package --vulnerable --include-transitive | tee vulns.txt` then
`! grep -q "has the following vulnerable packages" vulns.txt`.
3. Add a job that runs `docker build -f Dockerfile.linux .` so the production image is
build-tested on every push.
**Done when:**
- Temporarily re-adding `` to the csproj makes CI
fail; reverting makes it pass.
- A deliberately old package version makes the vulnerability step fail.
### TODO-29: Make backups consistent and automatic
**Files:** `README.md`, server cron (documented, not committed)
**Depends on:** none
**Why (one line):** tar-ing a live SQLite database mid-write can produce a corrupt
backup; SQLite's own backup command produces a consistent snapshot even while the app runs.
**Do:**
1. Replace the README backup one-liner with a consistent snapshot, e.g.:
`docker run --rm -v app_data:/data -v /root/backups:/backup alpine sh -c "apk add -q sqlite && sqlite3 /data/StitchingBeehive.db \".backup /backup/beehive-$(date +%F).db\" && tar czf /backup/uploads-$(date +%F).tar.gz -C /data uploads"`
2. Document a nightly cron entry and a retention rule (e.g. keep 14 days).
3. **[HUMAN]** Arrange an off-box copy (rclone to any cloud storage, or even a weekly
download) — a backup that lives only on the VPS dies with the VPS.
4. **Test a restore once**: restore into a fresh volume, start the app, see real data.
**Done when:** a restore from last night's backup onto a clean volume boots with content
intact, and the off-box copy exists.
### TODO-30: Establish a patch cadence for the container
**Files:** `README.md`
**Depends on:** none
**Why (one line):** the base image's OS and .NET runtime patches only reach production
when the image is rebuilt — `restart: unless-stopped` keeps the *old* image running forever.
**Do:** Document (and put in a calendar) a monthly routine:
`git pull && docker compose build --pull && docker compose up -d`, plus
`docker image prune -f`. Note that CVE announcements for ASP.NET may warrant doing it
immediately.
**Done when:** README has an "Updating / patching" section and the first scheduled run
has happened.
### TODO-31: Health endpoint and container healthcheck
**Files:** `Program.cs`, `docker-compose.yml`
**Depends on:** none
**Do:** `builder.Services.AddHealthChecks().AddDbContextCheck()`
and `app.MapHealthChecks("/healthz")`; add a compose `healthcheck` hitting
`http://localhost:8080/healthz` (note: the aspnet base image ships no curl/wget — either
install curl in `Dockerfile.linux` or use a tiny `HEALTHCHECK` helper).
**Done when:**
- `curl -s http://localhost:5000/healthz` returns `Healthy`.
- `docker compose ps` shows the service as `healthy`.
### TODO-32: VPS baseline hardening and uptime monitoring [HUMAN]
**Depends on:** server exists.
1. SSH: key-only auth, disable root password login.
2. Firewall: allow 22/80/443 only (`ufw`); port 5000 stays closed — Caddy proxies to it
on localhost.
3. `unattended-upgrades` (Debian/Ubuntu) for OS security patches; `fail2ban` for SSH.
4. Point a free uptime monitor (e.g. UptimeRobot) at `https://thestitchingbeehive.com/healthz`
with email alerts.
**Done when:** an external port scan shows only 22/80/443, and stopping the container
produces an alert email within minutes.
### TODO-39: Remove the legacy file-based service-content channel
**Files:** `Controllers/AdminController.cs`, `Views/Admin/ServiceContentFiles.cshtml`,
`Views/Shared/_AdminLayout.cshtml`, `Services/ContentService.cs`, `Program.cs`,
`Models/Service.cs` (+ migration)
**Depends on:** none (found during a content audit, after the items above were numbered)
**Why (one line):** the public site renders service text from the database
(`Service.Content`), but the admin still has a linked "Service Content Files" page that
reads/writes loose `.txt` files in `wwwroot/content/` via `Service.ContentFileName` —
edits made there never appear on the site, and in the production container the save will
throw because the app user cannot write inside `/app/wwwroot`.
**Do:**
1. Delete the `ServiceContentFiles` (GET/POST) and `UploadServiceContentFile` actions,
the `ServiceContentFiles.cshtml` view, and its `_AdminLayout` nav link.
2. Delete `Services/ContentService.cs` and its DI registration in `Program.cs`
(`IContentService` has zero callers).
3. Drop `Service.ContentFileName` (property + migration).
**Done when:**
- `grep -rn "ContentFileName\|IContentService\|ServiceContentFiles" Controllers/ Services/ Views/ Models/ Program.cs` returns nothing.
- Admin can still edit service content via the DB-backed editor and the change shows on
`/Home/Services`; CI is green.
## Phase 10 — Polish
### TODO-33: Email Debbie when the contact form is submitted
**Files:** `Controllers/HomeController.cs`
**Depends on:** working SMTP credentials (TODO-22).
**Why (one line):** submissions currently land only in the admin panel — nobody is told,
so a customer's message can sit unread for weeks.
**Do:** After saving the `ContactSubmission`, call the existing `IEmailService` to send a
short notification to `EmailSettings:FromEmail`. Wrap in try/catch + log: a mail failure
must not break the customer's submission (the row is already saved).
**Done when:** submitting the form delivers an email containing the sender's name, email,
and message; with SMTP credentials removed, submission still succeeds and the failure is
logged.
### TODO-34: Contact form spam protection
**Files:** `Views/Home/Contact.cshtml`, `Controllers/HomeController.cs`
**Depends on:** TODO-25 (rate limiting is the first layer).
**Do:** Add a honeypot: a hidden input (e.g. named `Website`) that humans never fill;
if it arrives non-empty, pretend success but discard. Add a minimum-time check (hidden
timestamp field; submissions completed in under ~3 seconds are bots). Hold CAPTCHA in
reserve — only add one if spam actually gets through, since it costs real users friction.
**Done when:** a POST with the honeypot filled returns the success page but creates no
row; a normal browser submission still works.
### TODO-35: Fix email deliverability [HUMAN decision + config]
**Depends on:** TODO-22.
**Why (one line):** the app sends "From: debbier@thestitchingbeehive.com" through
smtp.gmail.com authenticated as a different gmail.com account — SPF/DKIM/DMARC alignment
fails, so notifications are likely to land in spam or be rejected.
**Do (choose one):**
- *Easiest:* set `EmailSettings:FromEmail` to the actual Gmail address used to
authenticate; or
- *Proper:* use the domain's own SMTP (the web host likely provides it) or a free
transactional tier (e.g. Brevo), and set SPF + DKIM DNS records for
`thestitchingbeehive.com`.
**Done when:** a contact-form notification arrives in a Gmail *and* an Outlook inbox (not
spam), and `dig TXT thestitchingbeehive.com` shows an SPF record matching the sender.
### TODO-36: robots.txt, sitemap, and per-page metadata
**Files:** `wwwroot/robots.txt` (new), static `sitemap.xml` (or a small controller),
`Views/Shared/_Layout.cshtml`, individual views
**Depends on:** none
**Do:**
1. `robots.txt`: allow all, `Disallow: /Admin/`, point at the sitemap.
2. `sitemap.xml` listing the public pages (a static file is fine; regenerate if tips get
individual URLs).
3. Unique `` and `` per public page; Open Graph tags
(title/description/image) so shared links look right on Facebook — quilting customers
are heavily on Facebook.
4. LocalBusiness JSON-LD (name, address/area, hours, phone) on the contact page for
local-search results.
**Done when:** `curl -s http://localhost:5000/robots.txt` and `/sitemap.xml` return 200;
every public page has a distinct title/description (view source); Facebook's sharing
debugger shows a proper preview card.
### TODO-37: Cache public pages
**Files:** `Program.cs`, `Controllers/HomeController.cs`
**Depends on:** none
**Why (one line):** every public page does DB queries on every request; content changes
only when Debbie edits it, so even a short cache removes nearly all load.
**Do:** Add output caching (`AddOutputCache`/`UseOutputCache`) with a ~5-minute policy
tagged `"content"` on the public GET actions (never on `/Admin` or POSTs), and call
`IOutputCacheStore.EvictByTagAsync("content", …)` from the admin save paths so edits show
immediately.
**Done when:** second request to `/` is served from cache (log shows no DB queries);
editing the hero text in admin is visible on the public site immediately.
### TODO-38: Accessibility and image polish
**Files:** `Models/GalleryItem.cs` (if alt text field missing), admin upload views,
public gallery/tip views
**Depends on:** none
**Do:**
1. Ensure every rendered `
` has meaningful `alt` text — give gallery items an
AltText/Description field surfaced in the admin form if one doesn't exist.
2. Add `loading="lazy"` and explicit `width`/`height` (or CSS aspect-ratio) to gallery
and tip images to stop layout shift.
3. Run the browser's Lighthouse audit; fix anything scored "easy" (contrast, label-for,
heading order).
**Done when:** Lighthouse accessibility score ≥ 90 on `/` and `/Home/Gallery`, and no
`
` without `alt` (`grep -rn "
` from Plausible or a self-hosted
GoatCounter container answers "is anyone visiting?" without cookie banners or Google.
---
## Suggested order of attack
1. **Now (before launch):** TODO-22 → 23, then 24 → 25 → 26, with 33/34 close behind
(the contact form is the whole point of the site).
2. **Launch week:** TODO-28…32 and 39 — CI, backups, patching, health, VPS hardening,
and removing the broken legacy admin page.
3. **First month:** TODO-27 (.NET 10, once Visual Studio is updated), 35, 36, 37, 38.
4. **Whenever motivation strikes:** Phase 11, one item at a time, spec first.