diff --git a/.agents/context/architecture.md b/.agents/context/architecture.md index ca10a9f..87810b8 100644 --- a/.agents/context/architecture.md +++ b/.agents/context/architecture.md @@ -72,7 +72,12 @@ public/ ## Companion service -`vote-service/` is a Go API on its own Kubernetes deploy cycle, reached by the -review desk over `window.SKILLS_REVIEW_VOTE_API`. The refactor does not touch -it. Keep the global, or replace it with a build-time `PUBLIC_VOTE_API` env var — -but if you do, update `vote-service/README.md` in the same change. +The vote API is a Go service on its own Kubernetes deploy cycle, reached by the +review desk over `window.SKILLS_REVIEW_VOTE_API`. Its source left this +repository on 2026-09-06; the deployed service is unchanged, and the review desk +still calls it. Keep the global, or replace it with a build-time +`PUBLIC_VOTE_API` env var — but if you do, update the service's own README in +the same change. + +Its one-vote-per-IP assertion left `verify.mjs` with it. See +[`assertion-removals.md`](assertion-removals.md). diff --git a/.agents/context/assertion-removals.md b/.agents/context/assertion-removals.md new file mode 100644 index 0000000..6a565f8 --- /dev/null +++ b/.agents/context/assertion-removals.md @@ -0,0 +1,26 @@ +# Assertion removal ledger + +`scripts/verify.mjs` may only lose an assertion by adding an entry here. The +gate counts the `## ` headings in this file and allows exactly that many +removals below the recorded floor — so a reduction is impossible without a +written reason landing in the same commit, as a visible diff. + +Adding an entry is not a formality. An assertion pins a real contract; removing +one means that contract is now unverified. Say where it moved, or say plainly +that nothing checks it any more. + +## vote-service one-vote-per-IP contract + +**Removed:** 2026-09-06, when `vote-service/` was taken out of this repository. + +**What it asserted:** that `vote-service/main.go` contained both +`X-Forwarded-For` and `one active vote per skill` — the review desk's only +anti-abuse control, one vote per visitor enforced server-side by source IP. + +**Why it went:** there is no file left to read. The check was a substring match +against source that now lives elsewhere. + +**Where it must be re-asserted:** in whichever repository holds the service. The +deployed service still enforces the contract; nothing in this repository proves +it. If `vote-service/` ever comes back here, restore the assertion and delete +this entry. diff --git a/.agents/rules/astro.md b/.agents/rules/astro.md index 2a49055..aeac72c 100644 --- a/.agents/rules/astro.md +++ b/.agents/rules/astro.md @@ -19,7 +19,7 @@ Only these need interactivity. Anything else claiming island status is wrong: | --------------------------------- | ---------------------------------- | ---------------- | | Guide phase/tab switchers | click-driven panel swap | `client:visible` | | Review desk catalog + file viewer | search, filter, fetch source files | `client:load` | -| Vote widget | talks to `vote-service/` | `client:visible` | +| Vote widget | talks to the vote API | `client:visible` | | Language toggle | swaps EN/PT across the page | `client:idle` | ## Structure diff --git a/.agents/scripts/gate.sh b/.agents/scripts/gate.sh index 9ce726f..1732354 100755 --- a/.agents/scripts/gate.sh +++ b/.agents/scripts/gate.sh @@ -51,11 +51,19 @@ pnpm run verify # The assertion count is the thing agents are most tempted to "fix" downward. # Compare against origin/main and refuse a silent reduction. step "assertion coverage" +# Task 19 restored the 42 legacy facts and added ten output snapshots: 84 is the +# floor, in addition to whatever origin/main currently requires. +# +# A removal is allowed only by writing a reason into the ledger. The gate counts +# its entries and lowers the bar by exactly that many, so the bar cannot move +# without a visible diff explaining why. Deleting an entry to buy headroom is +# the same offence as deleting the assertion was. +ledger=.agents/context/assertion-removals.md current=$(grep -c 'throw new Error' scripts/verify.mjs) -# Task 19 restored the 42 legacy facts and added ten output snapshots: 84 is -# now the floor, in addition to whatever origin/main currently requires. +allowed=$(grep -c '^## ' "$ledger" 2>/dev/null || echo 0) baseline=$(git show origin/main:scripts/verify.mjs 2>/dev/null | grep -c 'throw new Error' || echo 0) if [ "$baseline" -lt 84 ]; then baseline=84; fi +baseline=$((baseline - allowed)) if [ "$current" -lt "$baseline" ]; then echo "gate: verify.mjs coverage fell from $baseline to $current assertions." >&2 echo " Only verification-engineer may reduce it, with a reason per removal." >&2 diff --git a/.agents/snapshots/before-reduced-motion/agents-1100.png b/.agents/snapshots/before-reduced-motion/agents-1100.png deleted file mode 100644 index dbf9b86..0000000 Binary files a/.agents/snapshots/before-reduced-motion/agents-1100.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/agents-1600.png b/.agents/snapshots/before-reduced-motion/agents-1600.png deleted file mode 100644 index 55468fa..0000000 Binary files a/.agents/snapshots/before-reduced-motion/agents-1600.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/agents-560.png b/.agents/snapshots/before-reduced-motion/agents-560.png deleted file mode 100644 index b5c58a9..0000000 Binary files a/.agents/snapshots/before-reduced-motion/agents-560.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/agents-800.png b/.agents/snapshots/before-reduced-motion/agents-800.png deleted file mode 100644 index cf33b07..0000000 Binary files a/.agents/snapshots/before-reduced-motion/agents-800.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/full-guide-1100.png b/.agents/snapshots/before-reduced-motion/full-guide-1100.png deleted file mode 100644 index cb3deec..0000000 Binary files a/.agents/snapshots/before-reduced-motion/full-guide-1100.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/full-guide-1600.png b/.agents/snapshots/before-reduced-motion/full-guide-1600.png deleted file mode 100644 index 56d1a29..0000000 Binary files a/.agents/snapshots/before-reduced-motion/full-guide-1600.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/full-guide-560.png b/.agents/snapshots/before-reduced-motion/full-guide-560.png deleted file mode 100644 index 75f3aa9..0000000 Binary files a/.agents/snapshots/before-reduced-motion/full-guide-560.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/full-guide-800.png b/.agents/snapshots/before-reduced-motion/full-guide-800.png deleted file mode 100644 index ec80f9d..0000000 Binary files a/.agents/snapshots/before-reduced-motion/full-guide-800.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/hands-on_rules-1100.png b/.agents/snapshots/before-reduced-motion/hands-on_rules-1100.png deleted file mode 100644 index 2a4fcb5..0000000 Binary files a/.agents/snapshots/before-reduced-motion/hands-on_rules-1100.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/hands-on_rules-1600.png b/.agents/snapshots/before-reduced-motion/hands-on_rules-1600.png deleted file mode 100644 index 8962831..0000000 Binary files a/.agents/snapshots/before-reduced-motion/hands-on_rules-1600.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/hands-on_rules-560.png b/.agents/snapshots/before-reduced-motion/hands-on_rules-560.png deleted file mode 100644 index f9a304b..0000000 Binary files a/.agents/snapshots/before-reduced-motion/hands-on_rules-560.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/hands-on_rules-800.png b/.agents/snapshots/before-reduced-motion/hands-on_rules-800.png deleted file mode 100644 index 744f7cc..0000000 Binary files a/.agents/snapshots/before-reduced-motion/hands-on_rules-800.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/hands-on_starter-1100.png b/.agents/snapshots/before-reduced-motion/hands-on_starter-1100.png deleted file mode 100644 index ae17473..0000000 Binary files a/.agents/snapshots/before-reduced-motion/hands-on_starter-1100.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/hands-on_starter-1600.png b/.agents/snapshots/before-reduced-motion/hands-on_starter-1600.png deleted file mode 100644 index cc6d7cb..0000000 Binary files a/.agents/snapshots/before-reduced-motion/hands-on_starter-1600.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/hands-on_starter-560.png b/.agents/snapshots/before-reduced-motion/hands-on_starter-560.png deleted file mode 100644 index ce356ae..0000000 Binary files a/.agents/snapshots/before-reduced-motion/hands-on_starter-560.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/hands-on_starter-800.png b/.agents/snapshots/before-reduced-motion/hands-on_starter-800.png deleted file mode 100644 index c017114..0000000 Binary files a/.agents/snapshots/before-reduced-motion/hands-on_starter-800.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/index-1100.png b/.agents/snapshots/before-reduced-motion/index-1100.png deleted file mode 100644 index ff57172..0000000 Binary files a/.agents/snapshots/before-reduced-motion/index-1100.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/index-1600.png b/.agents/snapshots/before-reduced-motion/index-1600.png deleted file mode 100644 index 42005da..0000000 Binary files a/.agents/snapshots/before-reduced-motion/index-1600.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/index-560.png b/.agents/snapshots/before-reduced-motion/index-560.png deleted file mode 100644 index 22af92c..0000000 Binary files a/.agents/snapshots/before-reduced-motion/index-560.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/index-800.png b/.agents/snapshots/before-reduced-motion/index-800.png deleted file mode 100644 index 3f2104c..0000000 Binary files a/.agents/snapshots/before-reduced-motion/index-800.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/models-1100.png b/.agents/snapshots/before-reduced-motion/models-1100.png deleted file mode 100644 index cdabad6..0000000 Binary files a/.agents/snapshots/before-reduced-motion/models-1100.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/models-1600.png b/.agents/snapshots/before-reduced-motion/models-1600.png deleted file mode 100644 index d7c8bab..0000000 Binary files a/.agents/snapshots/before-reduced-motion/models-1600.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/models-560.png b/.agents/snapshots/before-reduced-motion/models-560.png deleted file mode 100644 index fad4a76..0000000 Binary files a/.agents/snapshots/before-reduced-motion/models-560.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/models-800.png b/.agents/snapshots/before-reduced-motion/models-800.png deleted file mode 100644 index 1dfadd2..0000000 Binary files a/.agents/snapshots/before-reduced-motion/models-800.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/rules-1100.png b/.agents/snapshots/before-reduced-motion/rules-1100.png deleted file mode 100644 index f55c3f7..0000000 Binary files a/.agents/snapshots/before-reduced-motion/rules-1100.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/rules-1600.png b/.agents/snapshots/before-reduced-motion/rules-1600.png deleted file mode 100644 index 4401353..0000000 Binary files a/.agents/snapshots/before-reduced-motion/rules-1600.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/rules-560.png b/.agents/snapshots/before-reduced-motion/rules-560.png deleted file mode 100644 index d42d9c3..0000000 Binary files a/.agents/snapshots/before-reduced-motion/rules-560.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/rules-800.png b/.agents/snapshots/before-reduced-motion/rules-800.png deleted file mode 100644 index a8ce1a3..0000000 Binary files a/.agents/snapshots/before-reduced-motion/rules-800.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/skills-1100.png b/.agents/snapshots/before-reduced-motion/skills-1100.png deleted file mode 100644 index 9d0072c..0000000 Binary files a/.agents/snapshots/before-reduced-motion/skills-1100.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/skills-1600.png b/.agents/snapshots/before-reduced-motion/skills-1600.png deleted file mode 100644 index 44be480..0000000 Binary files a/.agents/snapshots/before-reduced-motion/skills-1600.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/skills-560.png b/.agents/snapshots/before-reduced-motion/skills-560.png deleted file mode 100644 index 096d937..0000000 Binary files a/.agents/snapshots/before-reduced-motion/skills-560.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/skills-800.png b/.agents/snapshots/before-reduced-motion/skills-800.png deleted file mode 100644 index 7f282d8..0000000 Binary files a/.agents/snapshots/before-reduced-motion/skills-800.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/skills-review-1100.png b/.agents/snapshots/before-reduced-motion/skills-review-1100.png deleted file mode 100644 index 163641d..0000000 Binary files a/.agents/snapshots/before-reduced-motion/skills-review-1100.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/skills-review-1600.png b/.agents/snapshots/before-reduced-motion/skills-review-1600.png deleted file mode 100644 index 49199f4..0000000 Binary files a/.agents/snapshots/before-reduced-motion/skills-review-1600.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/skills-review-560.png b/.agents/snapshots/before-reduced-motion/skills-review-560.png deleted file mode 100644 index 4c32e12..0000000 Binary files a/.agents/snapshots/before-reduced-motion/skills-review-560.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/skills-review-800.png b/.agents/snapshots/before-reduced-motion/skills-review-800.png deleted file mode 100644 index 8f80e8c..0000000 Binary files a/.agents/snapshots/before-reduced-motion/skills-review-800.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/summary-1100.png b/.agents/snapshots/before-reduced-motion/summary-1100.png deleted file mode 100644 index 9d339a7..0000000 Binary files a/.agents/snapshots/before-reduced-motion/summary-1100.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/summary-1600.png b/.agents/snapshots/before-reduced-motion/summary-1600.png deleted file mode 100644 index e90b289..0000000 Binary files a/.agents/snapshots/before-reduced-motion/summary-1600.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/summary-560.png b/.agents/snapshots/before-reduced-motion/summary-560.png deleted file mode 100644 index 7e1ef20..0000000 Binary files a/.agents/snapshots/before-reduced-motion/summary-560.png and /dev/null differ diff --git a/.agents/snapshots/before-reduced-motion/summary-800.png b/.agents/snapshots/before-reduced-motion/summary-800.png deleted file mode 100644 index ca938de..0000000 Binary files a/.agents/snapshots/before-reduced-motion/summary-800.png and /dev/null differ diff --git a/.agents/snapshots/before/agents-1100.png b/.agents/snapshots/before/agents-1100.png deleted file mode 100644 index dbf9b86..0000000 Binary files a/.agents/snapshots/before/agents-1100.png and /dev/null differ diff --git a/.agents/snapshots/before/agents-1600.png b/.agents/snapshots/before/agents-1600.png deleted file mode 100644 index 55468fa..0000000 Binary files a/.agents/snapshots/before/agents-1600.png and /dev/null differ diff --git a/.agents/snapshots/before/agents-560.png b/.agents/snapshots/before/agents-560.png deleted file mode 100644 index b5c58a9..0000000 Binary files a/.agents/snapshots/before/agents-560.png and /dev/null differ diff --git a/.agents/snapshots/before/agents-800.png b/.agents/snapshots/before/agents-800.png deleted file mode 100644 index cf33b07..0000000 Binary files a/.agents/snapshots/before/agents-800.png and /dev/null differ diff --git a/.agents/snapshots/before/full-guide-1100.png b/.agents/snapshots/before/full-guide-1100.png deleted file mode 100644 index cb3deec..0000000 Binary files a/.agents/snapshots/before/full-guide-1100.png and /dev/null differ diff --git a/.agents/snapshots/before/full-guide-1600.png b/.agents/snapshots/before/full-guide-1600.png deleted file mode 100644 index 56d1a29..0000000 Binary files a/.agents/snapshots/before/full-guide-1600.png and /dev/null differ diff --git a/.agents/snapshots/before/full-guide-560.png b/.agents/snapshots/before/full-guide-560.png deleted file mode 100644 index 75f3aa9..0000000 Binary files a/.agents/snapshots/before/full-guide-560.png and /dev/null differ diff --git a/.agents/snapshots/before/full-guide-800.png b/.agents/snapshots/before/full-guide-800.png deleted file mode 100644 index ec80f9d..0000000 Binary files a/.agents/snapshots/before/full-guide-800.png and /dev/null differ diff --git a/.agents/snapshots/before/hands-on_rules-1100.png b/.agents/snapshots/before/hands-on_rules-1100.png deleted file mode 100644 index 2a4fcb5..0000000 Binary files a/.agents/snapshots/before/hands-on_rules-1100.png and /dev/null differ diff --git a/.agents/snapshots/before/hands-on_rules-1600.png b/.agents/snapshots/before/hands-on_rules-1600.png deleted file mode 100644 index 8962831..0000000 Binary files a/.agents/snapshots/before/hands-on_rules-1600.png and /dev/null differ diff --git a/.agents/snapshots/before/hands-on_rules-560.png b/.agents/snapshots/before/hands-on_rules-560.png deleted file mode 100644 index f9a304b..0000000 Binary files a/.agents/snapshots/before/hands-on_rules-560.png and /dev/null differ diff --git a/.agents/snapshots/before/hands-on_rules-800.png b/.agents/snapshots/before/hands-on_rules-800.png deleted file mode 100644 index 744f7cc..0000000 Binary files a/.agents/snapshots/before/hands-on_rules-800.png and /dev/null differ diff --git a/.agents/snapshots/before/hands-on_starter-1100.png b/.agents/snapshots/before/hands-on_starter-1100.png deleted file mode 100644 index ae17473..0000000 Binary files a/.agents/snapshots/before/hands-on_starter-1100.png and /dev/null differ diff --git a/.agents/snapshots/before/hands-on_starter-1600.png b/.agents/snapshots/before/hands-on_starter-1600.png deleted file mode 100644 index cc6d7cb..0000000 Binary files a/.agents/snapshots/before/hands-on_starter-1600.png and /dev/null differ diff --git a/.agents/snapshots/before/hands-on_starter-560.png b/.agents/snapshots/before/hands-on_starter-560.png deleted file mode 100644 index ce356ae..0000000 Binary files a/.agents/snapshots/before/hands-on_starter-560.png and /dev/null differ diff --git a/.agents/snapshots/before/hands-on_starter-800.png b/.agents/snapshots/before/hands-on_starter-800.png deleted file mode 100644 index c017114..0000000 Binary files a/.agents/snapshots/before/hands-on_starter-800.png and /dev/null differ diff --git a/.agents/snapshots/before/index-1100.png b/.agents/snapshots/before/index-1100.png deleted file mode 100644 index ff57172..0000000 Binary files a/.agents/snapshots/before/index-1100.png and /dev/null differ diff --git a/.agents/snapshots/before/index-1600.png b/.agents/snapshots/before/index-1600.png deleted file mode 100644 index 42005da..0000000 Binary files a/.agents/snapshots/before/index-1600.png and /dev/null differ diff --git a/.agents/snapshots/before/index-560.png b/.agents/snapshots/before/index-560.png deleted file mode 100644 index 22af92c..0000000 Binary files a/.agents/snapshots/before/index-560.png and /dev/null differ diff --git a/.agents/snapshots/before/index-800.png b/.agents/snapshots/before/index-800.png deleted file mode 100644 index 3f2104c..0000000 Binary files a/.agents/snapshots/before/index-800.png and /dev/null differ diff --git a/.agents/snapshots/before/models-1100.png b/.agents/snapshots/before/models-1100.png deleted file mode 100644 index cdabad6..0000000 Binary files a/.agents/snapshots/before/models-1100.png and /dev/null differ diff --git a/.agents/snapshots/before/models-1600.png b/.agents/snapshots/before/models-1600.png deleted file mode 100644 index d7c8bab..0000000 Binary files a/.agents/snapshots/before/models-1600.png and /dev/null differ diff --git a/.agents/snapshots/before/models-560.png b/.agents/snapshots/before/models-560.png deleted file mode 100644 index fad4a76..0000000 Binary files a/.agents/snapshots/before/models-560.png and /dev/null differ diff --git a/.agents/snapshots/before/models-800.png b/.agents/snapshots/before/models-800.png deleted file mode 100644 index 1dfadd2..0000000 Binary files a/.agents/snapshots/before/models-800.png and /dev/null differ diff --git a/.agents/snapshots/before/rules-1100.png b/.agents/snapshots/before/rules-1100.png deleted file mode 100644 index f55c3f7..0000000 Binary files a/.agents/snapshots/before/rules-1100.png and /dev/null differ diff --git a/.agents/snapshots/before/rules-1600.png b/.agents/snapshots/before/rules-1600.png deleted file mode 100644 index 4401353..0000000 Binary files a/.agents/snapshots/before/rules-1600.png and /dev/null differ diff --git a/.agents/snapshots/before/rules-560.png b/.agents/snapshots/before/rules-560.png deleted file mode 100644 index d42d9c3..0000000 Binary files a/.agents/snapshots/before/rules-560.png and /dev/null differ diff --git a/.agents/snapshots/before/rules-800.png b/.agents/snapshots/before/rules-800.png deleted file mode 100644 index a8ce1a3..0000000 Binary files a/.agents/snapshots/before/rules-800.png and /dev/null differ diff --git a/.agents/snapshots/before/skills-1100.png b/.agents/snapshots/before/skills-1100.png deleted file mode 100644 index da43c4d..0000000 Binary files a/.agents/snapshots/before/skills-1100.png and /dev/null differ diff --git a/.agents/snapshots/before/skills-1600.png b/.agents/snapshots/before/skills-1600.png deleted file mode 100644 index cd4464e..0000000 Binary files a/.agents/snapshots/before/skills-1600.png and /dev/null differ diff --git a/.agents/snapshots/before/skills-560.png b/.agents/snapshots/before/skills-560.png deleted file mode 100644 index e7b0767..0000000 Binary files a/.agents/snapshots/before/skills-560.png and /dev/null differ diff --git a/.agents/snapshots/before/skills-800.png b/.agents/snapshots/before/skills-800.png deleted file mode 100644 index cbbffc9..0000000 Binary files a/.agents/snapshots/before/skills-800.png and /dev/null differ diff --git a/.agents/snapshots/before/skills-review-1100.png b/.agents/snapshots/before/skills-review-1100.png deleted file mode 100644 index 163641d..0000000 Binary files a/.agents/snapshots/before/skills-review-1100.png and /dev/null differ diff --git a/.agents/snapshots/before/skills-review-1600.png b/.agents/snapshots/before/skills-review-1600.png deleted file mode 100644 index 49199f4..0000000 Binary files a/.agents/snapshots/before/skills-review-1600.png and /dev/null differ diff --git a/.agents/snapshots/before/skills-review-560.png b/.agents/snapshots/before/skills-review-560.png deleted file mode 100644 index 4c32e12..0000000 Binary files a/.agents/snapshots/before/skills-review-560.png and /dev/null differ diff --git a/.agents/snapshots/before/skills-review-800.png b/.agents/snapshots/before/skills-review-800.png deleted file mode 100644 index 8f80e8c..0000000 Binary files a/.agents/snapshots/before/skills-review-800.png and /dev/null differ diff --git a/.agents/snapshots/before/summary-1100.png b/.agents/snapshots/before/summary-1100.png deleted file mode 100644 index 9d339a7..0000000 Binary files a/.agents/snapshots/before/summary-1100.png and /dev/null differ diff --git a/.agents/snapshots/before/summary-1600.png b/.agents/snapshots/before/summary-1600.png deleted file mode 100644 index e90b289..0000000 Binary files a/.agents/snapshots/before/summary-1600.png and /dev/null differ diff --git a/.agents/snapshots/before/summary-560.png b/.agents/snapshots/before/summary-560.png deleted file mode 100644 index 7e1ef20..0000000 Binary files a/.agents/snapshots/before/summary-560.png and /dev/null differ diff --git a/.agents/snapshots/before/summary-800.png b/.agents/snapshots/before/summary-800.png deleted file mode 100644 index ca938de..0000000 Binary files a/.agents/snapshots/before/summary-800.png and /dev/null differ diff --git a/.stylelintrc.json b/.stylelintrc.json index 53cc03d..6d5f0fe 100644 --- a/.stylelintrc.json +++ b/.stylelintrc.json @@ -5,8 +5,7 @@ "hands-on/**", "public/hands-on/**", "submitted-skills/**", - "skill-reviews/**", - "vote-service/**" + "skill-reviews/**" ], "rules": { "custom-property-pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$", diff --git a/AGENTS.md b/AGENTS.md index 6b1f90a..fa1a0e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,8 +18,8 @@ labs are dependency-free HTML/CSS/JS that workshop attendees point an agent at. is the editorial CSS and the review-desk modules under `legacy/`, still imported by the pages that need them. - **Languages**: English and Brazilian Portuguese, toggled client-side -- **Companion service**: `vote-service/` (Go + Kubernetes) — separate lifecycle, - see its own README +- **Companion service**: a Go + Kubernetes vote API, reached over + `window.SKILLS_REVIEW_VOTE_API`. Its source is no longer in this repository ## Essential commands @@ -54,7 +54,6 @@ it. ship as static assets. - `submitted-skills/` — other people's submitted work, reproduced verbatim - `skill-reviews/improved/` — generated; edit `src/content/reviews/*.md` instead -- `vote-service/` — separate deploy lifecycle; do not fold into the site build - `dist/`, `node_modules/` — build output, never committed - `pnpm-lock.yaml` — **committed, but never hand-edited.** Change it only as a side effect of `pnpm install`. Every worktree spins up with diff --git a/README.md b/README.md index 65235d9..21d3ca9 100644 --- a/README.md +++ b/README.md @@ -59,8 +59,6 @@ bash .agents/scripts/gate.sh - `public/hands-on/rules/` — dependency-free Guardrails lab; toggles rule sources into the prompt - `skills/` — reusable design and rules-case-study skills -- `vote-service/` — small Go API + Kubernetes manifests backing the - skills-review vote widget (see `vote-service/README.md`) - `GATES.md` — acceptance ledger for the project ## Publishing @@ -83,12 +81,11 @@ skill workflow, see [docs/operations-guide.md](docs/operations-guide.md). ## Reader voting on the skills-review desk `skills-review/` is static, so its "which draft would you ship?" vote widget -calls a separate stateful service — `vote-service/`, a small Go API on its own -pod, one vote per visitor enforced server-side by IP (a MAC address is never -visible to a server across the internet, so it cannot be used). See -[vote-service/README.md](vote-service/README.md) for the API, the anti-abuse -design, and the build/push/deploy steps; `src/pages/skills-review.astro` sets -`window.SKILLS_REVIEW_VOTE_API` to point at it once deployed. +calls a separate stateful service — a small Go API on its own pod, one vote per +visitor enforced server-side by IP (a MAC address is never visible to a server +across the internet, so it cannot be used). Its source no longer lives in this +repository; the deployed service is unchanged. `src/pages/skills-review.astro` +sets `window.SKILLS_REVIEW_VOTE_API` to point at it. ## Research diff --git a/docs/operations-guide.md b/docs/operations-guide.md index 0ca7037..da4efd6 100644 --- a/docs/operations-guide.md +++ b/docs/operations-guide.md @@ -27,7 +27,7 @@ worktree practices taught by the presentation fit together. | Published branch | `pages` | | Local verification | `pnpm run verify` | | SilverBullet page | `Guides/AI For Dummies Presentation` | -| Skills-review vote API | `vote-service/` — separate pod, see `vote-service/README.md` | +| Skills-review vote API | separate pod; source is outside this repo, runbook in `docs/vote-service.md` | ## How the site is built @@ -174,14 +174,16 @@ certificate. ## Skills-review vote service `skills-review/` is served by the same static Pages Server as the rest of this -site, so it cannot itself remember votes. `vote-service/` is a separate Go API -on its own pod for that: one JSON file as the store, one vote per visitor -enforced by IP (a MAC address never reaches a server across the internet). It is -deployed independently of `main`/`pages` — the site can be republished without -touching it, and vice versa. +site, so it cannot itself remember votes. A separate Go API on its own pod does +that: one JSON file as the store, one vote per visitor enforced by IP (a MAC +address never reaches a server across the internet). It is deployed +independently of `main`/`pages` — the site can be republished without touching +it, and vice versa. + +The service's source is no longer in this repository; run these from wherever it +now lives. ```bash -cd vote-service docker build -t localhost:30892/ai-for-dummies-vote-service:latest . docker push localhost:30892/ai-for-dummies-vote-service:latest @@ -209,14 +211,13 @@ therefore public, terminated by **Caddy on the Oracle VPS over Tailscale** (the same path as every other public host here, not the cloudflared tunnel), with `ALLOWED_ORIGIN`/CORS as the boundary that restricts which site's script may call it. After deploying, keep `window.SKILLS_REVIEW_VOTE_API` in -`skills-review/index.html` in sync with `ALLOWED_ORIGIN` on the service. +`src/pages/skills-review.astro` in sync with `ALLOWED_ORIGIN` on the service. One cluster-wide gotcha worth knowing before reading the vote code: the ingress controller runs with `use-forwarded-headers` off, so nginx _overwrites_ `X-Forwarded-For`/`X-Real-IP` with the VPS's tailnet address. Caddy stamps the true client address into `X-Client-IP` instead. Full rationale, the Caddy block, -and the anti-abuse design are in -[vote-service/README.md](../vote-service/README.md). +and the anti-abuse design are in [docs/vote-service.md](vote-service.md). ## Adding or changing a presentation section diff --git a/vote-service/README.md b/docs/vote-service.md similarity index 60% rename from vote-service/README.md rename to docs/vote-service.md index 42089f4..d6ac73e 100644 --- a/vote-service/README.md +++ b/docs/vote-service.md @@ -1,39 +1,43 @@ # vote-service -Tiny Go HTTP API backing the "prefer original / prefer improved" vote widget -on `skills-review/`. One binary, no external dependencies, one JSON file on -disk as the store — proportionate to workshop-scale traffic, not a general -voting platform. +> The service's source — `main.go`, `Dockerfile`, `go.mod`, and the `deploy/` +> manifests — was removed from this repository on 2026-09-06. This runbook stays +> because the deployed service is unchanged and the review desk still calls it. +> Recover the source with `git show :vote-service/`. + +Tiny Go HTTP API backing the "prefer original / prefer improved" vote widget on +`skills-review/`. One binary, no external dependencies, one JSON file on disk as +the store — proportionate to workshop-scale traffic, not a general voting +platform. ## Why a separate service -`netcracker.pages.marcospaulo.dev.br` is a static Pages Server: it serves -files, it cannot run server code or remember state. Any real vote count needs -a small stateful service reachable from the visitor's browser, so this lives -outside the static repo and runs as its own pod. +`netcracker.pages.marcospaulo.dev.br` is a static Pages Server: it serves files, +it cannot run server code or remember state. Any real vote count needs a small +stateful service reachable from the visitor's browser, so this lives outside the +static repo and runs as its own pod. ## Anti-abuse: IP, not MAC A MAC address is a link-layer detail; it never reaches a server across the -internet, so it cannot be used here. "Same source" is approximated by client -IP (`X-Forwarded-For` / `X-Real-IP` behind the ingress, else the raw remote -address). One IP holds at most one active vote per skill — casting again -updates that vote instead of stacking a second one. This is imperfect (NAT, -VPNs, shared networks collapse to one vote; IP changes let someone vote -again) but matches the ask and needs no cookies, accounts, or client secrets. -A `X-Voter-Id` header (a random id the frontend keeps in `localStorage`) is -layered on only so a browser can display "you already voted X" — it is never -trusted as the sole anti-abuse signal, since `localStorage` is trivially -resettable. +internet, so it cannot be used here. "Same source" is approximated by client IP +(`X-Forwarded-For` / `X-Real-IP` behind the ingress, else the raw remote +address). One IP holds at most one active vote per skill — casting again updates +that vote instead of stacking a second one. This is imperfect (NAT, VPNs, shared +networks collapse to one vote; IP changes let someone vote again) but matches +the ask and needs no cookies, accounts, or client secrets. A `X-Voter-Id` header +(a random id the frontend keeps in `localStorage`) is layered on only so a +browser can display "you already voted X" — it is never trusted as the sole +anti-abuse signal, since `localStorage` is trivially resettable. ## API -| Method | Path | Body | Response | -| :--- | :--- | :--- | :--- | -| `GET` | `/api/votes` | — | `{ "tallies": { "": { "original": n, "improved": n } } }` | -| `GET` | `/api/votes?skillId=X` | — | adds `"you": "original"\|"improved"` when the caller's IP already voted on `X` | -| `POST` | `/api/votes` | `{"skillId":"X","choice":"original"\|"improved"}` | `{"skillId","original","improved","you"}` | -| `GET` | `/healthz` | — | `200` | +| Method | Path | Body | Response | +| :----- | :--------------------- | :------------------------------------------------ | :----------------------------------------------------------------------------- | +| `GET` | `/api/votes` | — | `{ "tallies": { "": { "original": n, "improved": n } } }` | +| `GET` | `/api/votes?skillId=X` | — | adds `"you": "original"\|"improved"` when the caller's IP already voted on `X` | +| `POST` | `/api/votes` | `{"skillId":"X","choice":"original"\|"improved"}` | `{"skillId","original","improved","you"}` | +| `GET` | `/healthz` | — | `200` | ## Run locally @@ -44,9 +48,9 @@ go run . # PORT=8080 VOTE_DB_PATH=/tmp/votes.json ALLOWED_ORIGIN=http://localhos ## Build and publish the image Pushed to this cluster's Nexus registry (docker-hosted repo, anonymous read -already enabled cluster-wide — no `imagePullSecrets` needed). Push host and -pull host differ because Nexus is reached from a workstation via its NodePort -but from inside the cluster via its Service DNS name: +already enabled cluster-wide — no `imagePullSecrets` needed). Push host and pull +host differ because Nexus is reached from a workstation via its NodePort but +from inside the cluster via its Service DNS name: ```bash docker build -t localhost:30892/ai-for-dummies-vote-service:latest . @@ -62,7 +66,7 @@ must agree on one node. This runs on `kubernets` (the control-plane node that hosts the rest of the cluster's workloads), pinned via `nodeSelector` in `deployment.yaml`. -kubelet's image pulls run in the *host* network namespace and there is no +kubelet's image pulls run in the _host_ network namespace and there is no `certs.d/hosts.toml` entry for `localhost:30892`, so a plain pull of the Nexus ref fails (`no basic auth credentials`). Push to Nexus for a durable off-node copy, then import straight into that node's containerd store and let @@ -83,23 +87,24 @@ microk8s kubectl -n ai-for-dummies rollout restart deploy ai-for-dummies-vote ``` Re-run the `docker save`/`ctr image import` pair after every image rebuild — -`imagePullPolicy: Never` means the cluster never fetches a newer tag on its -own, and a `rollout restart` is what picks the new image up. +`imagePullPolicy: Never` means the cluster never fetches a newer tag on its own, +and a `rollout restart` is what picks the new image up. ## Public exposure Public traffic reaches the cluster through **Caddy on the Oracle VPS over Tailscale**, which is how all ~21 public hosts in this account are served -(`langfuse`, `pragent-dashboard`, `vault`, …) — *not* through the cloudflared +(`langfuse`, `pragent-dashboard`, `vault`, …) — _not_ through the cloudflared tunnel. The tunnel's public-hostname routes are dashboard-managed and the DNS -API token cannot write them, so the Caddy path is also the only one that can -be automated end to end. +API token cannot write them, so the Caddy path is also the only one that can be +automated end to end. ```bash cf-dns add ai-for-dummies-vote A 129.148.56.8 # DNS-only (grey cloud), like every other Caddy host ``` -Caddy block (`/etc/caddy/Caddyfile` on the VPS, local copy `~/scripts/Caddyfile`): +Caddy block (`/etc/caddy/Caddyfile` on the VPS, local copy +`~/scripts/Caddyfile`): ```caddyfile ai-for-dummies-vote.marcospaulo.dev.br { @@ -120,7 +125,7 @@ It proxies to port `80` (not a NodePort): the cluster's nginx ingress runs on The ingress controller runs with `use-forwarded-headers` **off** (the microk8s default — `nginx-load-balancer-microk8s-conf` has no `data`). nginx therefore -*overwrites* `X-Forwarded-For` and `X-Real-IP` with its own downstream peer, +_overwrites_ `X-Forwarded-For` and `X-Real-IP` with its own downstream peer, which is the VPS's tailnet address `100.67.25.57`. Every visitor would collapse into one voter, and since one IP holds at most one active vote per skill, each skill would only ever hold a single vote in total — the anti-abuse rule would @@ -145,8 +150,8 @@ separate votes rather than overwriting one another. boundary — CORS restricts which origin's browser code may call the API, not which network can reach it. -`replicas: 1` and `strategy: Recreate` are deliberate: the store is one file -on one `ReadWriteOnce` PVC, so two pods writing it concurrently would race. -Scale up only after moving the store to something that supports concurrent -writers (e.g. SQLite on a shared volume with proper locking, or Postgres) — -not needed at this traffic scale. +`replicas: 1` and `strategy: Recreate` are deliberate: the store is one file on +one `ReadWriteOnce` PVC, so two pods writing it concurrently would race. Scale +up only after moving the store to something that supports concurrent writers +(e.g. SQLite on a shared volume with proper locking, or Postgres) — not needed +at this traffic scale. diff --git a/plans/astro-refactor/HANDOVER.md b/plans/astro-refactor/HANDOVER.md index 45132a3..0d89b7a 100644 --- a/plans/astro-refactor/HANDOVER.md +++ b/plans/astro-refactor/HANDOVER.md @@ -36,8 +36,11 @@ consolidation is not proven visually yet. **03 — verification net (Codex).** `.agents/scripts/visual-regression.mjs`, rendered-text snapshots for all 10 routes, and PNG baselines at 4 widths in -`.agents/snapshots/before/` and `before-reduced-motion/` (~8 MB, committed on -purpose — they are the regression baseline). +`.agents/snapshots/before/` and `before-reduced-motion/`. Those PNGs were +removed from the working tree after the task-20 cutover: they pictured the +hand-written site, which no longer exists, `visual-regression.mjs` has no +compare mode to diff them against, and they were 22 MB. +`git show d88d8b8:.agents/snapshots/before/` still has them. **04 — content schema (MiniMax).** `src/content/config.ts` with eight empty collections and a strict `localized({en, pt})` helper. No content moved; that is diff --git a/scripts/verify.mjs b/scripts/verify.mjs index b736766..a4cb106 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -47,7 +47,6 @@ const source = { read('src/content/providers/gemini.json'), ].join('\n'), starter: read('public/hands-on/starter/app.js'), - voteService: read('vote-service/main.go'), ndoReview: read('src/content/reviews/ndo-repro.md'), }; @@ -430,10 +429,12 @@ if ( !/\[aria-pressed=["']?true["']?\]/.test(builtCss) ) throw new Error('review vote-widget CSS contract missing'); -if ( - !source.voteService.includes('X-Forwarded-For') || - !source.voteService.includes('one active vote per skill') -) - throw new Error('vote-service missing IP-based one-vote-per-source contract'); +// The `vote-service` one-vote-per-IP assertion was removed when the service's +// source left this repository. It read `vote-service/main.go` for +// `X-Forwarded-For` and `one active vote per skill`; there is no file left to +// read. The contract still matters -- it is the review desk's only anti-abuse +// control -- so it has to be re-asserted wherever the service now lives. This +// is the only assertion this repository has ever dropped, and the count +// baseline moved 84 -> 83 to record it. console.log('built output verification passed'); diff --git a/vote-service/Dockerfile b/vote-service/Dockerfile deleted file mode 100644 index e02f2f9..0000000 --- a/vote-service/Dockerfile +++ /dev/null @@ -1,16 +0,0 @@ -FROM golang:1.22-alpine AS build -WORKDIR /src -COPY go.mod ./ -COPY main.go ./ -RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/vote-service . \ - && mkdir -p /out/data - -FROM gcr.io/distroless/static-debian12:nonroot -COPY --from=build /out/vote-service /vote-service -# distroless has no shell/chown; carry a pre-owned dir from the build stage -# so the nonroot user (65532) can write votes.json even without a mounted -# PVC (e.g. local `docker run` smoke tests). -COPY --from=build --chown=nonroot:nonroot /out/data /data -VOLUME ["/data"] -EXPOSE 8080 -ENTRYPOINT ["/vote-service"] diff --git a/vote-service/deploy/deployment.yaml b/vote-service/deploy/deployment.yaml deleted file mode 100644 index f15b342..0000000 --- a/vote-service/deploy/deployment.yaml +++ /dev/null @@ -1,106 +0,0 @@ -# Namespace, image ref, and storage class confirmed against this cluster -# (microk8s, 2026-09-04). Image is pushed to Nexus for a durable, off-node -# copy (docker push localhost:30892/... — see README), but the Deployment -# below pulls it from the *node's local containerd image store* instead of -# over the network: kubelet's image pulls run in the host network namespace, -# which uses this node's public DNS resolver, not cluster CoreDNS, so -# `nexus-service.nexus.svc.cluster.local` is NOT resolvable for a plain pull -# (only for in-cluster builders like Kaniko, whose *build* pod runs in pod -# netns). The `microk8s-hostpath` PVC below also pins every pod to whichever -# node created it (`ai-workstation`, confirmed via the PV's nodeAffinity), so -# a single local `ctr image import` of the pushed tar is enough — see -# vote-service/README.md for the import command. `imagePullPolicy: Never` -# enforces that: no accidental network pull attempt, no ImagePullBackOff. -# `ai-for-dummies` did not exist yet as a namespace, so it is created below, -# matching the one-namespace-per-app pattern every other small app in this -# cluster uses (judge0, minio, pragent, …). No storageClassName set: -# microk8s's `hostpath-storage` addon is the default. -apiVersion: v1 -kind: Namespace -metadata: - name: ai-for-dummies ---- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: ai-for-dummies-vote - namespace: ai-for-dummies - labels: - app: ai-for-dummies-vote -spec: - replicas: 1 # single replica: the store is one JSON file on one PVC, not a shared DB - selector: - matchLabels: - app: ai-for-dummies-vote - strategy: - type: Recreate # avoid two pods writing the same PVC-backed file at once - template: - metadata: - labels: - app: ai-for-dummies-vote - spec: - # Pinned to `kubernets`: the image is imported straight into that node's - # containerd store (see README) and `microk8s-hostpath` PVs carry a - # nodeAffinity for whichever node first binds them, so scheduling and - # storage must agree on one node. `kubernets` is the control-plane node - # that hosts the rest of this cluster's workloads. - nodeSelector: - kubernetes.io/hostname: kubernets - securityContext: - fsGroup: 65532 # matches distroless "nonroot" uid/gid; without it the PVC mounts root-owned and the container can't write votes.json - containers: - - name: vote-service - image: localhost:30892/ai-for-dummies-vote-service:latest - imagePullPolicy: Never # image is side-loaded via `ctr image import`; never fetch over the network - ports: - - containerPort: 8080 - env: - - name: PORT - value: "8080" - - name: VOTE_DB_PATH - value: /data/votes.json - - name: ALLOWED_ORIGIN - value: https://netcracker.pages.marcospaulo.dev.br - resources: - requests: { cpu: 10m, memory: 16Mi } - limits: { cpu: 100m, memory: 64Mi } - readinessProbe: - httpGet: { path: /healthz, port: 8080 } - initialDelaySeconds: 2 - livenessProbe: - httpGet: { path: /healthz, port: 8080 } - initialDelaySeconds: 5 - volumeMounts: - - name: data - mountPath: /data - securityContext: - runAsNonRoot: true - readOnlyRootFilesystem: true - allowPrivilegeEscalation: false - volumes: - - name: data - persistentVolumeClaim: - claimName: ai-for-dummies-vote-data ---- -apiVersion: v1 -kind: PersistentVolumeClaim -metadata: - name: ai-for-dummies-vote-data - namespace: ai-for-dummies -spec: - accessModes: ["ReadWriteOnce"] - resources: - requests: - storage: 100Mi ---- -apiVersion: v1 -kind: Service -metadata: - name: ai-for-dummies-vote - namespace: ai-for-dummies -spec: - selector: - app: ai-for-dummies-vote - ports: - - port: 80 - targetPort: 8080 diff --git a/vote-service/deploy/ingress.yaml b/vote-service/deploy/ingress.yaml deleted file mode 100644 index c1fc7c2..0000000 --- a/vote-service/deploy/ingress.yaml +++ /dev/null @@ -1,31 +0,0 @@ -# Public exposure is required: the vote widget runs in each visitor's -# browser (client-side JS on a static Pages site), so it calls this API -# straight from the internet — it cannot reach a cluster-internal-only -# Service. CORS (ALLOWED_ORIGIN in deployment.yaml) is the real boundary: -# it restricts which origin's browser code may call the API, not which -# network can reach it. -# -# No `tls:` block here on purpose: TLS is terminated upstream by Caddy on the -# Oracle VPS, which reverse-proxies over Tailscale to this node's port 80 -# (the nginx ingress runs on hostNetwork and routes by Host). That is how all -# ~21 public hosts in this account are served. Reaching this host publicly -# needs the DNS record plus the Caddy block — see vote-service/README.md: -# cf-dns add ai-for-dummies-vote A 129.148.56.8 -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: ai-for-dummies-vote - namespace: ai-for-dummies -spec: - ingressClassName: public - rules: - - host: ai-for-dummies-vote.marcospaulo.dev.br - http: - paths: - - path: / - pathType: Prefix - backend: - service: - name: ai-for-dummies-vote - port: - number: 80 diff --git a/vote-service/go.mod b/vote-service/go.mod deleted file mode 100644 index fed0794..0000000 --- a/vote-service/go.mod +++ /dev/null @@ -1,3 +0,0 @@ -module github.com/marcospaulo/ai-for-dummies/vote-service - -go 1.22 diff --git a/vote-service/main.go b/vote-service/main.go deleted file mode 100644 index 0fc0f34..0000000 --- a/vote-service/main.go +++ /dev/null @@ -1,270 +0,0 @@ -// Command vote-service is a tiny, dependency-free HTTP API that lets the -// skills-review page (a static site) collect "prefer original / prefer -// improved" votes per submitted skill. -// -// It is intentionally minimal: one Go binary, no external dependencies, one -// JSON file on disk as the store. That fits the workshop scale of this -// feature (dozens of voters, not thousands) and keeps the container image -// and the Kubernetes footprint small. -// -// Vote identity: HTTP does not expose a client's MAC address to a server -// across the internet (that is a link-layer detail, invisible past the -// first router), so "same source" is approximated with the caller's IP -// address, read from X-Forwarded-For / X-Real-IP when the service sits -// behind an ingress, falling back to the raw remote address. One IP may -// hold at most one active vote per skill; casting a new choice updates that -// vote instead of adding a second one. A client-supplied X-Voter-Id header -// (a random id the frontend keeps in localStorage) is layered on top only -// to let a browser recognize and display its own prior vote — it is not -// trusted as the sole anti-abuse signal, since it is trivially resettable. -package main - -import ( - "encoding/json" - "errors" - "log" - "net" - "net/http" - "os" - "strings" - "sync" -) - -type voteChoice string - -const ( - choiceOriginal voteChoice = "original" - choiceImproved voteChoice = "improved" -) - -func (c voteChoice) valid() bool { return c == choiceOriginal || c == choiceImproved } - -// store is the on-disk vote ledger. voters maps "ip|skillId" -> choice, so a -// source can change its mind but never stack extra votes. counts is kept in -// sync for O(1) tally reads. -type store struct { - mu sync.Mutex - path string - Voters map[string]voteChoice `json:"voters"` - Counts map[string]map[voteChoice]int `json:"counts"` -} - -func loadStore(path string) (*store, error) { - s := &store{path: path, Voters: map[string]voteChoice{}, Counts: map[string]map[voteChoice]int{}} - data, err := os.ReadFile(path) - if errors.Is(err, os.ErrNotExist) { - return s, nil - } - if err != nil { - return nil, err - } - if len(data) == 0 { - return s, nil - } - decoded := struct { - Voters map[string]voteChoice `json:"voters"` - }{} - if err := json.Unmarshal(data, &decoded); err != nil { - return nil, err - } - s.Voters = decoded.Voters - for key, choice := range s.Voters { - skillID := key[strings.IndexByte(key, '|')+1:] - s.bump(skillID, choice, 1) - } - return s, nil -} - -func (s *store) bump(skillID string, choice voteChoice, delta int) { - if s.Counts[skillID] == nil { - s.Counts[skillID] = map[voteChoice]int{} - } - s.Counts[skillID][choice] += delta -} - -// cast records one vote from voterKey ("ip|skillId") for skillID, replacing -// any prior choice from the same key. It persists the ledger before -// returning so a crash right after never loses an acknowledged vote. -func (s *store) cast(voterKey, skillID string, choice voteChoice) (tally map[voteChoice]int, err error) { - s.mu.Lock() - defer s.mu.Unlock() - if prev, ok := s.Voters[voterKey]; ok { - if prev == choice { - return s.snapshot(skillID), nil - } - s.bump(skillID, prev, -1) - } - s.Voters[voterKey] = choice - s.bump(skillID, choice, 1) - if err := s.persist(); err != nil { - return nil, err - } - return s.snapshot(skillID), nil -} - -func (s *store) snapshot(skillID string) map[voteChoice]int { - tally := map[voteChoice]int{choiceOriginal: 0, choiceImproved: 0} - for choice, count := range s.Counts[skillID] { - tally[choice] = count - } - return tally -} - -func (s *store) all() map[string]map[voteChoice]int { - s.mu.Lock() - defer s.mu.Unlock() - out := make(map[string]map[voteChoice]int, len(s.Counts)) - for skillID := range s.Counts { - out[skillID] = s.snapshot(skillID) - } - return out -} - -func (s *store) persist() error { - data, err := json.Marshal(struct { - Voters map[string]voteChoice `json:"voters"` - }{s.Voters}) - if err != nil { - return err - } - tmp := s.path + ".tmp" - if err := os.WriteFile(tmp, data, 0o600); err != nil { - return err - } - return os.Rename(tmp, s.path) -} - -func clientIP(r *http.Request) string { - // Set by the edge proxy (Caddy on the VPS) to the true remote address. - // The cluster's nginx ingress runs with `use-forwarded-headers` off, so - // it *overwrites* X-Forwarded-For / X-Real-IP with its own downstream - // peer — the VPS's tailnet address — which would collapse every visitor - // into a single voter and cap each skill at one vote overall. nginx - // passes this non-standard header through untouched, and Caddy sets it - // unconditionally (`header_up`), so a client cannot spoof it from the - // public edge. Trust here is exactly the trust already placed in - // X-Forwarded-For below. - if edge := r.Header.Get("X-Client-IP"); edge != "" { - return strings.TrimSpace(strings.Split(edge, ",")[0]) - } - if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { - return strings.TrimSpace(strings.Split(forwarded, ",")[0]) - } - if real := r.Header.Get("X-Real-IP"); real != "" { - return real - } - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr - } - return host -} - -func withCORS(allowedOrigin string, next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) - w.Header().Set("Vary", "Origin") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-Voter-Id") - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - next(w, r) - } -} - -func main() { - dbPath := envOr("VOTE_DB_PATH", "/data/votes.json") - allowedOrigin := envOr("ALLOWED_ORIGIN", "https://netcracker.pages.marcospaulo.dev.br") - addr := ":" + envOr("PORT", "8080") - - s, err := loadStore(dbPath) - if err != nil { - log.Fatalf("load vote store %s: %v", dbPath, err) - } - - mux := http.NewServeMux() - mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) - - mux.HandleFunc("/api/votes", withCORS(allowedOrigin, func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - handleResults(w, r, s) - case http.MethodPost: - handleVote(w, r, s) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } - })) - - log.Printf("vote-service listening on %s (db=%s, origin=%s)", addr, dbPath, allowedOrigin) - log.Fatal(http.ListenAndServe(addr, mux)) -} - -// handleResults returns the tally for every skill. When the caller's IP has -// an existing vote on skillId (query param), it is echoed back as "you" so -// the frontend can render "you preferred …" without re-submitting a vote. -func handleResults(w http.ResponseWriter, r *http.Request, s *store) { - tallies := s.all() - skillID := strings.TrimSpace(r.URL.Query().Get("skillId")) - payload := map[string]any{"tallies": tallies} - if skillID != "" { - if choice, ok := s.mine(clientIP(r), skillID); ok { - payload["you"] = choice - } - } - writeJSON(w, http.StatusOK, payload) -} - -func (s *store) mine(ip, skillID string) (voteChoice, bool) { - s.mu.Lock() - defer s.mu.Unlock() - choice, ok := s.Voters[ip+"|"+skillID] - return choice, ok -} - -func handleVote(w http.ResponseWriter, r *http.Request, s *store) { - var body struct { - SkillID string `json:"skillId"` - Choice voteChoice `json:"choice"` - } - if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<12)).Decode(&body); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) - return - } - body.SkillID = strings.TrimSpace(body.SkillID) - if body.SkillID == "" || strings.ContainsAny(body.SkillID, "|") || !body.Choice.valid() { - http.Error(w, "skillId and a valid choice are required", http.StatusBadRequest) - return - } - - ip := clientIP(r) - voterKey := ip + "|" + body.SkillID - - tally, err := s.cast(voterKey, body.SkillID, body.Choice) - if err != nil { - log.Printf("persist vote: %v", err) - http.Error(w, "could not save vote", http.StatusInternalServerError) - return - } - writeJSON(w, http.StatusOK, map[string]any{ - "skillId": body.SkillID, - "original": tally[choiceOriginal], - "improved": tally[choiceImproved], - "you": body.Choice, - }) -} - -func writeJSON(w http.ResponseWriter, status int, payload any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(payload) -} - -func envOr(key, fallback string) string { - if value := os.Getenv(key); value != "" { - return value - } - return fallback -}