dev #22

Merged
elirtf merged 23 commits from dev into main 2026-09-14 20:06:31 +00:00
Owner
No description provided.
Investigation spike, not an integration. Findings:

- "Quo" is the rebranded OpenPhone; same REST API + signed (HMAC)
  webhooks. call.completed exposes direction, from/to, answered-vs-missed
  status, and answeredAt/completedAt — enough to count a real answered
  inbound call as a Conversion.
- Full API/webhook access appears to be a Scale-plan (~$35/user/mo)
  feature per secondary sources; confirm against our actual plan.
- Routing into Umami is feasible and stateless (env-gated webhook route
  -> verify HMAC -> filter to answered inbound -> server-side Umami
  event; no PII, no CSP change). Build design captured for later.
- Limitation: a PSTN call can't be attributed back to the web
  session/source, so it's an aggregate answered-call count that
  complements the tel: click rather than replacing/upgrading it.

Recommendation: defer until we're already on Scale and call volume
outgrows reading Quo's native call log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build tickets 03 + 08 (.scratch/local-seo-build), executing the on-page +
measurement decisions from the local-seo-strategy wayfinder map:
- Region-anchored, brand-first titles + refreshed meta descriptions
  (home, managed-it, base default); drop the stale "nationwide" claim.
- contact-submit and phone-click events now carry {page: pathname} so
  conversions attribute to the landing page across the multi-page site.
- Update home/managed-it title assertions to the new spec strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scope nav.links link-colour rules with :not(.btn) so the header CTA keeps
its own dark-on-frost styling instead of inheriting muted --mist text
(near-invisible). The standalone conversion-UX fix flagged alongside the
local-SEO effort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build ticket 01 (.scratch/local-seo-build):
- Header wordmark now reads "AVIAN TECHNOLOGIES GROUP" (was bare "AVIAN
  TECHNOLOGIES" — the exact string that collides with the unrelated optical
  "Avian Technologies"; the name-collision disambiguator per map ticket 02).
- Add the chosen monochrome logo (Logos-10) as static/avian-logo.png.
- Add a 1200x630 OG share card (frost-tinted logo + wordmark + tagline) for
  the social meta wiring in build ticket 02.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The wayfinder strategy map (.scratch/local-seo-strategy) — 8 resolved
decision tickets + the IA prototype — and the execution handoff
(.scratch/local-seo-build) — spec + 8 ordered build tickets. Tickets 01,
03, 08 marked done; the rest await ticket 02 or owner inputs (fact sheet,
social URLs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build ticket 02 (.scratch/local-seo-build):
- Emit one canonical ProfessionalService entity on every page (base.html),
  @id avian.club/#organization, with legalName + sameAs (GBP) — the
  name-collision disambiguation from map ticket 02 — and the full North-LA
  areaServed town list.
- Replace the home-only LocalBusiness node with the shared entity.
- Add og:image + twitter:image (the OG card), og:locale, and upgrade
  twitter:card to summary_large_image.
- Tests updated: entity type/@id/legalName + og:image/twitter markers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build ticket 04 (.scratch/local-seo-build):
- Managed IT: Service (provider -> #organization entity) + one-level
  BreadcrumbList + FAQPage built from the page's real 9-question FAQ.
- Billing: BreadcrumbList.
- Test parses every JSON-LD block on both pages (validates the hand-written
  FAQ escaping) and asserts the node types + 9 questions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Build tickets 05, 06, 07 (.scratch/local-seo-build) — completes the Medium
four-page IA from the wayfinder map:
- /software-development (Software & AI Development): custom software, AI
  integrations, API/systems integration — Service + BreadcrumbList schema,
  on-page contact form. Copy is honest per the owner fact sheet (modest AI
  framing, "reply within one business day", no SLA claims).
- /areas-we-serve: Ruston + Monroe lead blurbs + surrounding-town long-tail
  (Grambling/Dubach/Calhoun/Jonesboro/Farmerville/Homer/Bastrop); Shreveport
  and Alexandria intentionally excluded (graduate-later). BreadcrumbList.
- Both routes registered + added to the sitemap.
- Services dropdown nav (variant A): Managed IT + Software & AI Development;
  Areas We Serve top-level. Hover/focus/click, keyboard-accessible (Esc, blur).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prior-build copy claimed more than the owner stands behind. Reconciled
across both pages (visible copy + FAQ HTML and its FAQPage schema):
- drop "300+ user environments" (career experience, not a new-business metric)
- drop the "two engineers / individual years" headcount framing; keep the
  aggregate "40+ years of combined experience"
- replace "same-business-day response" / after-hours / on-call / "jump the
  line" claims with the committed "reply within one business day" (no SLA)
- proof strips now read "40+ yrs combined · Direct-to-engineer · Reply within
  1 business day"
Tests updated to assert the new wording and that the dropped claims are gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Header brand mark is now the chosen emblem (avian-logo.png), frost-tinted
  via CSS mask so the monochrome logo reads on the dark nav — replaces the
  placeholder shield.
- Footer gains LinkedIn + Facebook icons. Links come from LINKEDIN_URL /
  FACEBOOK_URL / TWITTER_URL env vars; blank -> "#" placeholder until the
  pages exist. When set, the same URLs are appended to the Organization
  schema sameAs (entity disambiguation, map ticket 02/05) — no code change.
- .env.example documents the new vars; test covers placeholder + env sameAs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- base.html: entity schema email now renders {{ contact_email }} so a
  CONTACT_EMAIL env change reaches the schema, not just the footer (ADR/CLAUDE.md _LazySetting rule)
- software_development.html: add missing internal link to /areas-we-serve
  (ticket 05 DoD required Managed IT + Areas We Serve links)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A stopped web container could no longer retake port 443: tailscaled holds
100.120.125.105:443 for an unrelated `tailscale serve` proxy, and a wildcard
bind cannot be taken while a specific-IP bind exists. The overlay failed with
"address already in use" and the site stayed down behind a Cloudflare 521.

Publish on ${BIND_IP:-0.0.0.0} instead, with BIND_IP set to the LAN address the
router forwards to. Default behaviour is unchanged for any host without the
conflict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two junk signups arrived back to back with a placeholder address and a made-up
phone number. Nothing on the form stopped either one.

Validation now runs in two passes: Pydantic for shape and size, then the new
validation module for meaning, inside the handler so junk still counts against
the sender's rate limit. Placeholder addresses (d@d.com), reserved and
disposable domains, and invented NANP numbers are rejected with a 422 naming
the field; script.js shows the message inline and clears it as the visitor
fixes it. Phone is now required.

Bot signals — the off-screen honeypot, a sub-3s fill time, link-stuffed
messages — return a plain {"ok": true} and are dropped without sending. A 422
would tell a script which trick to drop next time.

Rate limits are keyed per client IP and per email address: 8 POSTs/hour of any
kind, 3 deliverable submissions/hour, 2 per address, 60s between accepted
sends. Attempts and accepted sends are counted separately so a visitor who
mistypes their email twice can still send the message they came to send.

Keying on request.client.host would have counted the wrong thing entirely:
uvicorn takes the rightmost forwarded address, which behind Cloudflare is a
rotating edge node — it would miss a repeat sender and could throttle
strangers sharing an edge. Use CF-Connecting-IP, the one value Cloudflare
overwrites per request.

Also: uvicorn leaves the root logger unconfigured, so every log.info in the app
went nowhere — including the existing "email not configured, logging
submission" fallback. Configure it, or the record of what was dropped is
invisible. The four landing pages now share one contact_form macro so the
anti-spam fields cannot drift apart, and a failed delivery logs the whole
submission rather than costing the lead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Forgejo Actions workflow plus the runner it needs. Two jobs: ruff and pytest on
python:3.12-alpine, the version the Dockerfile ships; and a `docker build` of
the backend image with a `docker compose config` over both compose files, so a
broken Dockerfile or overlay fails here rather than at deploy time.

Jobs run against a private Docker-in-Docker daemon, not the host's, because the
runner may share a box with the containers serving the live site.

Writing this found a real gap: `ruff check backend` from the repo root and
`ruff check .` from inside backend/ disagreed about which imports are
first-party, so lint passed locally while failing from the documented command.
Set src, add validation to known-first-party, and sort main.py the way both
now agree on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The runner polls Forgejo over outbound HTTPS, so its host needs no inbound
connectivity at all — no public address, no forwarded ports, no firewall
opening. That makes a disposable VM the natural home for it, away from the
containers serving the live site.

Nothing in ci/ had to change to support this; it already uses named volumes and
no host paths. Add a cloud-init file that works with libvirt, a cloud
provider's user-data, or Proxmox, and cap the dind service so a build cannot
starve whatever it shares a box with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the third job and the runner that runs it. Two runners now: a build runner
on any spare machine, and a deploy runner on the production host itself,
registered with a label nothing else has.

Running the deploy where the site lives is what keeps the credentials out of
this: no SSH key in Forgejo, no inbound port on the server, and a build job on
the other machine has no route to production even if a pull request tries. Both
runners poll over outbound HTTPS.

The job encodes the deploy that has bitten us twice — both compose files, never
just the base — then waits for the backend healthcheck and fails the run unless
the site answers 200. It fast-forwards production's checkout rather than
resetting it, so a dirty tree stops the deploy instead of losing work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the template-and-clone flow, the snippets storage detail that makes
cicustom silently do nothing when missed, and the reason a VM beats an LXC
container here: the build job runs Docker-in-Docker.

cloud-init installs qemu-guest-agent so the hypervisor can report the guest's
address and shut it down cleanly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Bump fastapi/starlette/uvicorn/pydantic: clears 8 starlette advisories,
  incl. the Range-header DoS on FileResponse and unvalidated Host/path in
  request.url. Starlette 1.x dropped the old TemplateResponse signature, so
  calls now pass the request first.
- Caddy strips CF-Connecting-IP from peers outside Cloudflare's ranges, so a
  request that skips the edge can't spoof its way past the per-IP throttle.
- Headers: X-XSS-Protection 0 (per OWASP), Permissions-Policy, CORP,
  style-src split into elem/attr so injected <style> is blocked, Server
  header removed at uvicorn and Caddy.
- Containers: backend runs as non-root with cap_drop ALL, no-new-privileges,
  read-only rootfs; Caddy keeps only NET_BIND_SERVICE.
- web healthcheck probes backend:8000 so a container that lost its network
  endpoint (seen after a host reboot) shows unhealthy instead of Up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CfhjywU1uTTtSyWivkgSi9
security(caddy): send security headers on redirect responses too
Some checks failed
CI / lint-and-test (push) Has been cancelled
CI / image (push) Has been cancelled
CI / lint-and-test (pull_request) Has been cancelled
CI / image (pull_request) Has been cancelled
CI / deploy (push) Has been cancelled
CI / deploy (pull_request) Has been cancelled
f9f6e08f89
Scanners flag http://avian.club and https://www.avian.club for missing
CSP / X-Frame-Options / X-Content-Type-Options: those are redirects Caddy
answers itself, so the app's middleware never runs. Explicit http:// sites
replace the automatic HTTPS redirect, and a shared snippet puts the headers
on every redirect Caddy issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CfhjywU1uTTtSyWivkgSi9
elirtf merged commit 2ff1857c99 into main 2026-09-14 20:06:31 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
elirtf/msp!22
No description provided.