diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3218536 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Tooling caches, not part of the published site. +.serena/ +__pycache__/ diff --git a/README.md b/README.md index e601ba1..fcdc115 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ npm run verify - `hands-on/rules/` — dependency-free Guardrails lab; toggles rule sources into the prompt - `rules/` — bilingual case study of skills, CLI ratchets, Husky, and PR review - `skills/` — reusable design and rules-case-study skills, plus an interactive package anatomy explorer +- `skills-review/` — static review desk for submitted skills; its reader vote widget calls the separate `vote-service` +- `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 @@ -59,6 +61,16 @@ Server and Actions deployment path. For the complete authoring, verification, publication, rollback, worktree, and 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; `skills-review/index.html` sets +`window.SKILLS_REVIEW_VOTE_API` to point at it once deployed. + ## Research See [docs/references/README.md](docs/references/README.md) for official Claude, diff --git a/docs/operations-guide.md b/docs/operations-guide.md index 9d7b05e..1ca2846 100644 --- a/docs/operations-guide.md +++ b/docs/operations-guide.md @@ -27,6 +27,7 @@ worktree practices taught by the presentation fit together. | Published branch | `pages` | | Local verification | `npm run verify` | | SilverBullet page | `Guides/AI For Dummies Presentation` | +| Skills-review vote API | `vote-service/` — separate pod, see `vote-service/README.md` | ## How the site is built @@ -148,6 +149,54 @@ https://netcracker.pages.marcospaulo.dev.br/ai-for-dummies/ produce `ERR_SSL_PROTOCOL_ERROR` because it does not match the wildcard TLS 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. + +```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 + +# kubelet cannot pull that ref (no certs.d/hosts.toml for localhost:30892 → +# `no basic auth credentials`), so side-load into containerd instead and let +# `imagePullPolicy: Never` skip the network pull. Use microk8s's bundled ctr. +docker save localhost:30892/ai-for-dummies-vote-service:latest -o /tmp/vote-service.tar +/snap/microk8s/current/bin/ctr --address /var/snap/microk8s/common/run/containerd.sock \ + --namespace k8s.io image import /tmp/vote-service.tar + +microk8s kubectl apply -f deploy/deployment.yaml # namespace + Deployment + PVC + Service +microk8s kubectl apply -f deploy/ingress.yaml +microk8s kubectl -n ai-for-dummies rollout restart deploy ai-for-dummies-vote +``` + +Namespace `ai-for-dummies`, `ingressClassName: public`, no per-ingress TLS. +The Deployment is pinned to node `kubernets` with a `nodeSelector`: the +`microk8s-hostpath` PV carries a `nodeAffinity` for whichever node first binds +it, so scheduling and storage have to agree on one node. + +The vote widget's browser-side `fetch` calls must reach the API over the public +internet — a cluster-internal-only Service would be unreachable from a +visitor's browser even if the Pages Server happens to run on the same +network. Exposure is 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. + +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). + ## Adding or changing a presentation section 1. Add semantic HTML and stable `data-*` hooks in the focused chapter or `full-guide/index.html`; keep `index.html` as the short route map. diff --git a/scripts/verify.mjs b/scripts/verify.mjs index bd256f4..84d9165 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -22,6 +22,8 @@ const modelsHtml = read('models/index.html'); const agentsHtml = read('agents/index.html'); const skillsHtml = read('skills/index.html'); const reviewCatalog = read('skills-review/catalog.js'); +const reviewVoteJs = read('skills-review/vote.js'); +const voteService = read('vote-service/main.go'); for (const url of ['https://code.claude.com/docs/en/sub-agents','https://code.claude.com/docs/en/skills','https://code.claude.com/docs/en/worktrees','https://git-scm.com/docs/git-worktree.html','https://developers.openai.com/codex/skills']) if (!refs.includes(url)) throw new Error(`missing reference ${url}`); console.log('content verification passed'); for (const token of ['data-phase="plan"','data-phase="build"','data-phase="review"','data-tree="main"','data-tree="ui"','data-worker="ui"','data-route="plan"','data-model-provider="openai"','data-model-provider="claude"','data-model-provider="gemini"','data-effort="low"','data-effort="medium"','data-effort="high"','data-skill-file="skill"','data-skill-step="observe"','data-skill-step="validate"','data-common-skill="ponytail"','data-common-skill="caveman"','data-common-skill="unlazy"','id="hands-on"','data-copy-target="prompt-install-skills"','data-copy-target="prompt-basic"','data-copy-target="prompt-skills"','hands-on/starter/','additional-reading.md','role="tablist"',' document.querySelector(selector); @@ -158,7 +159,8 @@ function previewMarkup(entry, available) { } function renderDetail() { const entry = state.selected; const available = packageFiles(entry); - $('#detail').innerHTML = `
${escape(entry.status)}

${escape(entry.title)}

Submitted by ${escape(entry.author)} ·

THE JOB

${escape(entry.focus)}

WHAT'S ALREADY WORKING
    ${entry.wins.map((item) => `
  • ${escape(item)}
  • `).join('')}
HIGHEST-VALUE IMPROVEMENTS
    ${entry.improve.map((item) => `
  • ${escape(item)}
  • `).join('')}
${previewMarkup(entry, available)}`; + $('#detail').innerHTML = `
${escape(entry.status)}

${escape(entry.title)}

Submitted by ${escape(entry.author)} ·

THE JOB

${escape(entry.focus)}

WHAT'S ALREADY WORKING
    ${entry.wins.map((item) => `
  • ${escape(item)}
  • `).join('')}
HIGHEST-VALUE IMPROVEMENTS
    ${entry.improve.map((item) => `
  • ${escape(item)}
  • `).join('')}
${previewMarkup(entry, available)}`; + renderVoteWidget($('#vote-widget'), entry.id); $('#detail').querySelectorAll('[data-file]').forEach((button) => button.addEventListener('click', () => { state.file = available.find((item) => item.name === button.dataset.file) || available[0]; state.rendered = false; state.diff = false; syncUrl(); renderDetail(); loadSelectedFile(); })); $('#detail').querySelectorAll('[data-preview]').forEach((button) => button.addEventListener('click', () => { state.preview = button.dataset.preview; state.lens = false; state.rendered = false; state.diff = false; syncUrl(); renderDetail(); loadSelectedFile(); })); $('#detail').querySelectorAll('[data-lens]').forEach((button) => button.addEventListener('click', () => { state.lens = !state.lens; state.rendered = false; state.diff = false; syncUrl(); renderDetail(); })); diff --git a/skills-review/index.html b/skills-review/index.html index 1c86a37..aec1d30 100644 --- a/skills-review/index.html +++ b/skills-review/index.html @@ -6,8 +6,10 @@ Submitted Skills — Review Desk - - + + + +
@@ -25,7 +27,7 @@

How to use this desk

Compare.
Then choose.

-
  1. Select a submission, or open an author URL.
  2. Read the gentle review before judging the draft.
  3. Choose Preview Markdown in the file toolbar to render either version.
  4. Copy or download the version you want.
+
  1. Select a submission, or open an author URL.
  2. Read the gentle review before judging the draft.
  3. Choose Preview Markdown in the file toolbar to render either version.
  4. Copy or download the version you want, then vote for the draft you would ship.
@@ -36,8 +38,8 @@

The recommendations follow the open Agent Skills format: valid frontmatter for discovery, progressive disclosure for context economy, deterministic scripts for fragile repeated mechanics, and behavioral evaluation rather than a checklist of pretty headings.

Format specification ↗Writing practices ↗Evaluation loop ↗Scripts guide ↗
-
Share an author with ?author=Name, or one review with ?author=Name&skill=skill-id&view=improved. To add a submission later: drop a package under submitted-skills/, add a tailored entry in skills-review/catalog.js, then run node scripts/build-skill-review.mjs.
+
Share an author with ?author=Name, or one review with ?author=Name&skill=skill-id&view=improved. To add a submission later: drop a package under submitted-skills/, add a tailored entry in skills-review/catalog.js, then run node scripts/build-skill-review.mjs. Votes call a separate service — see vote-service/ — one per visitor, tracked by network source.
- + diff --git a/skills-review/styles.css b/skills-review/styles.css index 7bde6fd..2eb99e1 100644 --- a/skills-review/styles.css +++ b/skills-review/styles.css @@ -1,4 +1,4 @@ -:root{--ink:#122534;--paper:#f6f3ed;--line:#d0d5d2;--muted:#65717a;--blue:#215675;--gold:#ebbf58;--violet:#6b668f}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--paper);font:15px/1.6 Arial,sans-serif}main{max-width:1500px;margin:auto;padding:0 4vw}.topbar{display:flex;justify-content:space-between;gap:20px;padding:24px 0;border-bottom:1px solid var(--line);color:var(--muted);font:700 10px monospace;letter-spacing:.08em;text-transform:uppercase}.topbar a{color:var(--ink);text-decoration:none}.hero{max-width:1040px;padding:105px 0 75px}.eyebrow,.status,.purpose span,.review-grid span,.extras span,.preview header span{font:700 10px monospace;letter-spacing:.1em}.eyebrow{color:#a7483f}.hero h1,.method h2{margin:14px 0;font-size:clamp(48px,8vw,112px);line-height:.92;letter-spacing:-.07em}.hero em,.method em{color:#a7483f;font-family:Georgia,serif;font-weight:400}.hero p:last-child{max-width:620px;color:var(--muted);font-size:18px}.principles{display:grid;grid-template-columns:repeat(4,1fr);border:1px solid var(--line);background:var(--line);gap:1px}.principles article{display:grid;gap:10px;min-height:175px;padding:22px;background:var(--paper)}.principles b{color:#a7483f;font:22px monospace}.principles strong{font-size:18px}.principles span{color:var(--muted);font-size:13px}.method{display:grid;grid-template-columns:1fr 1fr;gap:70px;align-items:end;padding:120px 0 55px}.method h2{font-size:clamp(40px,5vw,70px)}.method ol{margin:0;padding-left:20px;color:var(--muted)}.method li+li{margin-top:9px}.catalog{display:grid;grid-template-columns:320px minmax(0,1fr);border:1px solid var(--line);background:var(--line);gap:1px}.catalog aside{padding:24px;background:#e9eeed}.catalog label{display:block;margin:32px 0 7px;font:700 10px monospace;letter-spacing:.08em;text-transform:uppercase}.catalog input{width:100%;padding:12px;border:1px solid #9ba7a5;background:var(--paper);font:inherit}.count{color:var(--muted);font:11px monospace}#skill-list{display:grid;gap:1px;border-top:1px solid var(--line)}#skill-list button{display:grid;grid-template-columns:1fr auto;gap:3px;padding:14px;border:0;border-bottom:1px solid var(--line);color:var(--ink);background:transparent;text-align:left;cursor:pointer}#skill-list button span{grid-column:1/-1;color:var(--muted);font:10px monospace}#skill-list button strong{font-size:13px}#skill-list button small{color:#a7483f;font:9px monospace;text-transform:uppercase}#skill-list button:hover,#skill-list button.active{color:var(--paper);background:var(--ink)}#skill-list button.active span,#skill-list button.active small{color:var(--gold)}.detail{min-width:0;padding:38px;background:var(--paper)}.detail>header{display:flex;justify-content:space-between;gap:25px;align-items:start}.status{color:#a7483f}.detail h2{margin:5px 0;font-size:clamp(30px,4vw,58px);letter-spacing:-.06em}.detail header p{margin:0;color:var(--muted)}.switch{display:flex;border:1px solid var(--ink)}button{font:inherit}.switch button,.preview button{padding:9px 11px;border:0;background:transparent;cursor:pointer;font:700 10px monospace}.switch button.active{color:var(--paper);background:var(--ink)}.purpose{display:grid;grid-template-columns:150px 1fr;gap:20px;margin:45px 0 20px;padding:20px;background:var(--gold)}.purpose p{margin:0;font-size:18px;line-height:1.4}.review-grid{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--line)}.review-grid section{padding:22px;background:var(--paper)}.review-grid span{color:#a7483f}.review-grid ul{margin:14px 0 0;padding-left:20px}.review-grid li+li{margin-top:9px}.extras{margin:1px 0 25px;padding:18px 22px;color:var(--paper);background:var(--blue)}.extras span{display:block;margin-bottom:8px;color:var(--gold)}.preview{border:1px solid var(--ink);background:var(--ink)}.preview header{display:flex;justify-content:space-between;gap:20px;padding:14px;color:var(--paper);border-bottom:1px solid #486175}.preview header span{color:var(--gold)}.preview button{color:var(--paper);border:1px solid #486175}.preview button:hover{background:#29455a}.preview pre{max-height:540px;margin:0;padding:24px;overflow:auto;color:#d6e1e4;background:#0c1a25}.preview code{font:12px/1.65 ui-monospace,monospace;white-space:pre-wrap}.research{margin:100px 0;padding:35px;color:var(--paper);background:var(--violet)}.research .eyebrow{color:var(--gold)}.research>p:not(.eyebrow){max-width:850px;font:20px/1.45 Georgia,serif}.research div{display:flex;flex-wrap:wrap;gap:12px}.research a{padding:8px 10px;color:var(--paper);border:1px solid #ffffff66;font:10px monospace;text-decoration:none}footer{padding:10px 0 50px;color:var(--muted);font-size:12px}footer code{color:var(--ink)}button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid #a7483f;outline-offset:2px}@media(max-width:850px){.principles{grid-template-columns:1fr 1fr}.catalog{grid-template-columns:1fr}.method{grid-template-columns:1fr;gap:25px;padding-top:80px}.detail{padding:24px}.review-grid{grid-template-columns:1fr}}@media(max-width:530px){main{padding:0 16px}.topbar span{display:none}.hero{padding:65px 0 45px}.principles{grid-template-columns:1fr}.detail>header,.preview header{display:block}.switch{margin-top:18px;width:max-content}.purpose{grid-template-columns:1fr}.preview header div{margin-top:12px}} +:root{--ink:#122534;--paper:#f6f3ed;--line:#d0d5d2;--muted:#65717a;--blue:#215675;--gold:#ebbf58;--violet:#6b668f}*{box-sizing:border-box}body{margin:0;color:var(--ink);background:var(--paper);font:15px/1.6 Arial,sans-serif}main{max-width:1500px;margin:auto;padding:0 4vw}.topbar{display:flex;justify-content:space-between;gap:20px;padding:24px 0;border-bottom:1px solid var(--line);color:var(--muted);font:700 10px monospace;letter-spacing:.08em;text-transform:uppercase}.topbar a{color:var(--ink);text-decoration:none}.hero{max-width:1040px;padding:105px 0 75px}.eyebrow,.status,.purpose span,.review-grid span,.extras span,.preview header span{font:700 10px monospace;letter-spacing:.1em}.eyebrow{color:#a7483f}.hero h1,.method h2{margin:14px 0;font-size:clamp(48px,8vw,112px);line-height:.92;letter-spacing:-.07em}.hero em,.method em{color:#a7483f;font-family:Georgia,serif;font-weight:400}.hero p:last-child{max-width:620px;color:var(--muted);font-size:18px}.principles{display:grid;grid-template-columns:repeat(4,1fr);border:1px solid var(--line);background:var(--line);gap:1px}.principles article{display:grid;gap:10px;min-height:175px;padding:22px;background:var(--paper)}.principles b{color:#a7483f;font:22px monospace}.principles strong{font-size:18px}.principles span{color:var(--muted);font-size:13px}.method{display:grid;grid-template-columns:1fr 1fr;gap:70px;align-items:end;padding:120px 0 55px}.method h2{font-size:clamp(40px,5vw,70px)}.method ol{margin:0;padding-left:20px;color:var(--muted)}.method li+li{margin-top:9px}.catalog{display:grid;grid-template-columns:320px minmax(0,1fr);border:1px solid var(--line);background:var(--line);gap:1px}.catalog aside{padding:24px;background:#e9eeed}.catalog label{display:block;margin:32px 0 7px;font:700 10px monospace;letter-spacing:.08em;text-transform:uppercase}.catalog input{width:100%;padding:12px;border:1px solid #9ba7a5;background:var(--paper);font:inherit}.count{color:var(--muted);font:11px monospace}#skill-list{display:grid;gap:1px;border-top:1px solid var(--line)}#skill-list button{display:grid;grid-template-columns:1fr auto;gap:3px;padding:14px;border:0;border-bottom:1px solid var(--line);color:var(--ink);background:transparent;text-align:left;cursor:pointer}#skill-list button span{grid-column:1/-1;color:var(--muted);font:10px monospace}#skill-list button strong{font-size:13px}#skill-list button small{color:#a7483f;font:9px monospace;text-transform:uppercase}#skill-list button:hover,#skill-list button.active{color:var(--paper);background:var(--ink)}#skill-list button.active span,#skill-list button.active small{color:var(--gold)}.detail{min-width:0;padding:38px;background:var(--paper)}.detail>header{display:flex;justify-content:space-between;gap:25px;align-items:start}.status{color:#a7483f}.detail h2{margin:5px 0;font-size:clamp(30px,4vw,58px);letter-spacing:-.06em}.detail header p{margin:0;color:var(--muted)}.switch{display:flex;border:1px solid var(--ink)}button{font:inherit}.switch button,.preview button{padding:9px 11px;border:0;background:transparent;cursor:pointer;font:700 10px monospace}.switch button.active{color:var(--paper);background:var(--ink)}.purpose{display:grid;grid-template-columns:150px 1fr;gap:20px;margin:45px 0 20px;padding:20px;background:var(--gold)}.purpose p{margin:0;font-size:18px;line-height:1.4}.review-grid{display:grid;grid-template-columns:1fr 1fr;gap:1px;background:var(--line)}.review-grid section{padding:22px;background:var(--paper)}.review-grid span{color:#a7483f}.review-grid ul{margin:14px 0 0;padding-left:20px}.review-grid li+li{margin-top:9px}.extras{margin:1px 0 25px;padding:18px 22px;color:var(--paper);background:var(--blue)}.extras span{display:block;margin-bottom:8px;color:var(--gold)}.vote-widget{display:grid;gap:10px;margin:1px 0 25px;padding:18px 22px;color:var(--ink);background:#e5eeeb;border-left:4px solid var(--gold)}.vote-widget>span{color:var(--blue);font:700 10px monospace;letter-spacing:.1em}.vote-buttons{display:flex;gap:1px;background:var(--line)}.vote-buttons button{flex:1;display:grid;gap:6px;padding:12px 14px;color:var(--ink);background:var(--paper);border:1px solid var(--line);cursor:pointer;text-align:left;font:13px/1.3 inherit}.vote-buttons button b{color:var(--muted);font:11px monospace}.vote-buttons button[aria-pressed="true"]{color:var(--paper);background:var(--ink)}.vote-buttons button[aria-pressed="true"] b{color:var(--gold)}.vote-note{margin:0;color:var(--muted);font-size:12px}.preview{border:1px solid var(--ink);background:var(--ink)}.preview header{display:flex;justify-content:space-between;gap:20px;padding:14px;color:var(--paper);border-bottom:1px solid #486175}.preview header span{color:var(--gold)}.preview button{color:var(--paper);border:1px solid #486175}.preview button:hover{background:#29455a}.preview pre{max-height:540px;margin:0;padding:24px;overflow:auto;color:#d6e1e4;background:#0c1a25}.preview code{font:12px/1.65 ui-monospace,monospace;white-space:pre-wrap}.research{margin:100px 0;padding:35px;color:var(--paper);background:var(--violet)}.research .eyebrow{color:var(--gold)}.research>p:not(.eyebrow){max-width:850px;font:20px/1.45 Georgia,serif}.research div{display:flex;flex-wrap:wrap;gap:12px}.research a{padding:8px 10px;color:var(--paper);border:1px solid #ffffff66;font:10px monospace;text-decoration:none}footer{padding:10px 0 50px;color:var(--muted);font-size:12px}footer code{color:var(--ink)}button:focus-visible,input:focus-visible,a:focus-visible{outline:3px solid #a7483f;outline-offset:2px}@media(max-width:850px){.principles{grid-template-columns:1fr 1fr}.catalog{grid-template-columns:1fr}.method{grid-template-columns:1fr;gap:25px;padding-top:80px}.detail{padding:24px}.review-grid{grid-template-columns:1fr}}@media(max-width:530px){main{padding:0 16px}.topbar span{display:none}.hero{padding:65px 0 45px}.principles{grid-template-columns:1fr}.detail>header,.preview header{display:block}.switch{margin-top:18px;width:max-content}.purpose{grid-template-columns:1fr}.vote-buttons{flex-direction:column}.preview header div{margin-top:12px}} /* Review surface overrides: keep suggestions readable and packages navigable. */ .extras{color:var(--ink);background:#e5eeeb;border-left:4px solid var(--blue)}.extras span{color:var(--blue)}.extras p{margin:0}.file-tabs{display:flex;gap:1px;overflow-x:auto;padding:10px 14px;background:#122534;border-bottom:1px solid #486175}.file-tabs button{display:grid;gap:1px;min-width:max-content;padding:7px 10px;color:#d6e1e4;background:transparent;border:1px solid #486175;cursor:pointer;text-align:left;font:11px ui-monospace,monospace}.file-tabs button span{color:#ebbf58;font-size:9px;text-transform:uppercase}.file-tabs button.active,.file-tabs button:hover{color:#122534;background:#ebbf58}.file-tabs button.active span,.file-tabs button:hover span{color:#122534} diff --git a/skills-review/vote.js b/skills-review/vote.js new file mode 100644 index 0000000..fc6bb1a --- /dev/null +++ b/skills-review/vote.js @@ -0,0 +1,53 @@ +// Reader vote widget: "which draft would you ship?" per reviewed skill. +// The page itself is static (Gitea Pages), so this talks to a small +// separate API — see /vote-service in the repository root. One vote per +// source is enforced server-side by IP, not here; this module only renders +// state and remembers the local choice so a returning visitor sees it +// without re-voting. +const API_BASE = (window.SKILLS_REVIEW_VOTE_API || '').replace(/\/$/, ''); +const escape = (value) => value.replace(/[&<>"']/g, (character) => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' })[character]); + +function voterId() { + let id = localStorage.getItem('skills-review-voter-id'); + if (!id) { id = crypto.randomUUID(); localStorage.setItem('skills-review-voter-id', id); } + return id; +} + +async function api(path, options = {}) { + const response = await fetch(`${API_BASE}${path}`, { ...options, headers: { 'Content-Type': 'application/json', 'X-Voter-Id': voterId(), ...options.headers } }); + if (!response.ok) throw new Error(`vote API ${response.status}`); + return response.json(); +} + +function widgetMarkup(skillId, tally, you, unavailable) { + const total = (tally.original || 0) + (tally.improved || 0); + const share = (count) => total ? Math.round((count / total) * 100) : 0; + if (unavailable) return `
READER VOTE

Voting is offline right now — the vote service is not configured or unreachable.

`; + return `
+ WHICH DRAFT WOULD YOU SHIP? +
+ + +
+

${you ? `You voted ${you === 'original' ? 'original' : 'improved draft'}. Pick the other option to change it.` : 'One vote per visitor, tracked by network source.'}

+
`; +} + +export async function renderVoteWidget(container, skillId) { + if (!API_BASE) { container.innerHTML = widgetMarkup(skillId, {}, null, true); return; } + container.innerHTML = widgetMarkup(skillId, {}, null, false); + const cast = async (choice) => { + container.innerHTML = widgetMarkup(skillId, {}, null, false); + try { + const result = await api('/api/votes', { method: 'POST', body: JSON.stringify({ skillId, choice }) }); + container.innerHTML = widgetMarkup(skillId, { original: result.original, improved: result.improved }, result.you, false); + bind(); + } catch { container.innerHTML = widgetMarkup(skillId, {}, null, true); } + }; + function bind() { container.querySelectorAll('[data-vote]').forEach((button) => button.addEventListener('click', () => cast(button.dataset.vote))); } + try { + const result = await api(`/api/votes?skillId=${encodeURIComponent(skillId)}`); + container.innerHTML = widgetMarkup(skillId, result.tallies?.[skillId] || {}, result.you, false); + } catch { container.innerHTML = widgetMarkup(skillId, {}, null, true); } + bind(); +} diff --git a/vote-service/Dockerfile b/vote-service/Dockerfile new file mode 100644 index 0000000..e02f2f9 --- /dev/null +++ b/vote-service/Dockerfile @@ -0,0 +1,16 @@ +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/README.md b/vote-service/README.md new file mode 100644 index 0000000..42089f4 --- /dev/null +++ b/vote-service/README.md @@ -0,0 +1,152 @@ +# 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. + +## 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. + +## 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` | + +## Run locally + +```bash +go run . # PORT=8080 VOTE_DB_PATH=/tmp/votes.json ALLOWED_ORIGIN=http://localhost:4173 +``` + +## 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: + +```bash +docker build -t localhost:30892/ai-for-dummies-vote-service:latest . +docker push localhost:30892/ai-for-dummies-vote-service:latest +# pods pull the same image as: nexus-service.nexus.svc.cluster.local:8082/ai-for-dummies-vote-service:latest +``` + +## Deploy (microk8s) + +The `ai-for-dummies-vote-data` PVC uses `microk8s-hostpath`, whose PVs carry a +`nodeAffinity` for whichever node first binds them — so scheduling and storage +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 +`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 +`imagePullPolicy: Never` skip the network pull entirely — the same pattern the +`pragent-webhook` image uses in this cluster: + +```bash +docker save localhost:30892/ai-for-dummies-vote-service:latest -o /tmp/vote-service.tar +/snap/microk8s/current/bin/ctr --address /var/snap/microk8s/common/run/containerd.sock \ + --namespace k8s.io image import /tmp/vote-service.tar +# use microk8s's own bundled ctr, not the host's — different containerd major +# versions speak incompatible client/server protocols (`unknown service +# containerd.services.streaming.v1.Streaming` otherwise) + +microk8s kubectl apply -f deploy/deployment.yaml # namespace + Deployment + PVC + Service +microk8s kubectl apply -f deploy/ingress.yaml +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. + +## 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 +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. + +```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`): + +```caddyfile +ai-for-dummies-vote.marcospaulo.dev.br { + tls { + dns cloudflare + } + reverse_proxy 100.74.17.70:80 { + header_up Host {host} + header_up X-Client-IP {remote_host} + } +} +``` + +It proxies to port `80` (not a NodePort): the cluster's nginx ingress runs on +`hostNetwork` on `kubernets` and routes by `Host`. + +### Why `X-Client-IP` + +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, +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 +silently become a hard cap. + +Rather than flip `use-forwarded-headers` globally (it would change client-IP +handling for every other ingress in the cluster), Caddy stamps the true remote +address into `X-Client-IP`, a non-standard header nginx forwards untouched, and +`clientIP()` reads it first. `header_up` sets it unconditionally, so a public +client cannot spoof it; the trust placed in it is exactly the trust already +placed in `X-Forwarded-For`. + +Verified after deploy: requests from two distinct sources are recorded as two +separate votes rather than overwriting one another. + +## Frontend wiring + +`skills-review/index.html` sets `window.SKILLS_REVIEW_VOTE_API` to +`https://ai-for-dummies-vote.marcospaulo.dev.br`; keep it in sync with +`ALLOWED_ORIGIN` in `deployment.yaml` +(`https://netcracker.pages.marcospaulo.dev.br`), which is the real caller +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. diff --git a/vote-service/deploy/deployment.yaml b/vote-service/deploy/deployment.yaml new file mode 100644 index 0000000..f15b342 --- /dev/null +++ b/vote-service/deploy/deployment.yaml @@ -0,0 +1,106 @@ +# 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 new file mode 100644 index 0000000..c1fc7c2 --- /dev/null +++ b/vote-service/deploy/ingress.yaml @@ -0,0 +1,31 @@ +# 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 new file mode 100644 index 0000000..fed0794 --- /dev/null +++ b/vote-service/go.mod @@ -0,0 +1,3 @@ +module github.com/marcospaulo/ai-for-dummies/vote-service + +go 1.22 diff --git a/vote-service/main.go b/vote-service/main.go new file mode 100644 index 0000000..0fc0f34 --- /dev/null +++ b/vote-service/main.go @@ -0,0 +1,270 @@ +// 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 +}