feat: add reader vote widget and vote-service
The skills-review desk is static, so "which draft would you ship?" needs a stateful counterpart. vote-service is a small Go API on its own pod backed by a JSON file on a ReadWriteOnce PVC, with one active vote per skill per source IP as the anti-abuse rule and CORS (ALLOWED_ORIGIN) as the caller boundary. Deployment notes that differ from the obvious path, all confirmed against the live cluster: the image is side-loaded with `ctr image import` plus `imagePullPolicy: Never` because kubelet has no credentials for the Nexus ref; the pod is pinned to `kubernets` because the hostpath PV takes a nodeAffinity for whichever node first binds it; and public exposure is Caddy on the VPS, not the cloudflared tunnel. The ingress controller runs with `use-forwarded-headers` off, so nginx overwrites X-Forwarded-For with its own peer — every visitor would collapse into one voter and each skill would cap at one vote overall. Caddy stamps the true remote address into X-Client-IP, which nginx forwards untouched, and clientIP() reads that first. Scoped to this app rather than flipping the global flag, which would change client-IP handling for every other ingress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
# Tooling caches, not part of the published site.
|
||||||
|
.serena/
|
||||||
|
__pycache__/
|
||||||
@@ -42,6 +42,8 @@ npm run verify
|
|||||||
- `hands-on/rules/` — dependency-free Guardrails lab; toggles rule sources into the prompt
|
- `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
|
- `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/` — 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
|
- `GATES.md` — acceptance ledger for the project
|
||||||
|
|
||||||
## Publishing
|
## Publishing
|
||||||
@@ -59,6 +61,16 @@ Server and Actions deployment path.
|
|||||||
For the complete authoring, verification, publication, rollback, worktree, and
|
For the complete authoring, verification, publication, rollback, worktree, and
|
||||||
skill workflow, see [docs/operations-guide.md](docs/operations-guide.md).
|
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
|
## Research
|
||||||
|
|
||||||
See [docs/references/README.md](docs/references/README.md) for official Claude,
|
See [docs/references/README.md](docs/references/README.md) for official Claude,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ worktree practices taught by the presentation fit together.
|
|||||||
| Published branch | `pages` |
|
| Published branch | `pages` |
|
||||||
| Local verification | `npm run verify` |
|
| Local verification | `npm run verify` |
|
||||||
| SilverBullet page | `Guides/AI For Dummies Presentation` |
|
| 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
|
## 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
|
produce `ERR_SSL_PROTOCOL_ERROR` because it does not match the wildcard TLS
|
||||||
certificate.
|
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
|
## 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.
|
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.
|
||||||
|
|||||||
+8
-1
@@ -22,6 +22,8 @@ const modelsHtml = read('models/index.html');
|
|||||||
const agentsHtml = read('agents/index.html');
|
const agentsHtml = read('agents/index.html');
|
||||||
const skillsHtml = read('skills/index.html');
|
const skillsHtml = read('skills/index.html');
|
||||||
const reviewCatalog = read('skills-review/catalog.js');
|
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}`);
|
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');
|
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"','<table']) if (!html.includes(token)) throw new Error(`missing content ${token}`);
|
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"','<table']) if (!html.includes(token)) throw new Error(`missing content ${token}`);
|
||||||
@@ -46,7 +48,7 @@ for (const token of ['../summary/','../models/','../agents/','../skills/','chapt
|
|||||||
if (rulesHtml.includes('script src="http') || rulesHtml.includes('rel="stylesheet" href="http')) throw new Error('rules page has an external runtime dependency');
|
if (rulesHtml.includes('script src="http') || rulesHtml.includes('rel="stylesheet" href="http')) throw new Error('rules page has an external runtime dependency');
|
||||||
for (const token of ['@media(min-width:2200px)','@media(max-width:900px)','@media(max-width:600px)','prefers-reduced-motion']) if (!rulesCss.includes(token)) throw new Error(`missing rules responsive contract ${token}`);
|
for (const token of ['@media(min-width:2200px)','@media(max-width:900px)','@media(max-width:600px)','prefers-reduced-motion']) if (!rulesCss.includes(token)) throw new Error(`missing rules responsive contract ${token}`);
|
||||||
console.log('rules standalone verification passed');
|
console.log('rules standalone verification passed');
|
||||||
for (const token of ['id="catalog"','id="skill-filter"','id="skill-list"','id="detail"','Preview Markdown','styles.css?v=20260904-preview-toolbar','change-lens.css?v=20260904-preview-toolbar','app.js?v=20260904-preview-toolbar','?author=Name&skill=skill-id&view=improved']) if (!reviewHtml.includes(token)) throw new Error(`missing review page content ${token}`);
|
for (const token of ['id="catalog"','id="skill-filter"','id="skill-list"','id="detail"','Preview Markdown','styles.css?v=20260904-vote-widget','change-lens.css?v=20260904-vote-widget','app.js?v=20260904-vote-widget','?author=Name&skill=skill-id&view=improved','SKILLS_REVIEW_VOTE_API']) if (!reviewHtml.includes(token)) throw new Error(`missing review page content ${token}`);
|
||||||
for (const token of ["from './catalog.js'", "from './files.js'",'function renderList','function renderDetail','selectSkill','packageSummary','markdownHeadings','markdownToc','document.addEventListener(\'keydown\'','loadSelectedFile','schedulePackageSearch','fetchSource','packageSearchText','diffMarkup','diffRows','data-diff','searchParams.set(\'compare\'','markdownMarkup','data-render','preview-markdown','Preview Markdown','View source','FILE PREVIEW','searchParams.set(\'render\'','AUTHOR ·','SKILL ·','function selectFromUrl','function syncUrl','URLSearchParams','navigator.clipboard','document.execCommand','download','data-file','searchParams.set(\'file\'']) if (!reviewJs.includes(token)) throw new Error(`missing review interaction ${token}`);
|
for (const token of ["from './catalog.js'", "from './files.js'",'function renderList','function renderDetail','selectSkill','packageSummary','markdownHeadings','markdownToc','document.addEventListener(\'keydown\'','loadSelectedFile','schedulePackageSearch','fetchSource','packageSearchText','diffMarkup','diffRows','data-diff','searchParams.set(\'compare\'','markdownMarkup','data-render','preview-markdown','Preview Markdown','View source','FILE PREVIEW','searchParams.set(\'render\'','AUTHOR ·','SKILL ·','function selectFromUrl','function syncUrl','URLSearchParams','navigator.clipboard','document.execCommand','download','data-file','searchParams.set(\'file\'']) if (!reviewJs.includes(token)) throw new Error(`missing review interaction ${token}`);
|
||||||
for (const token of ['ndo-repro','gfiber-logging','confluence-page','diagram-plantuml','page-reviewer','unslop','spanish-naturalizer','draft-mr','semantic-diff-review','reference.md','files =']) if (!`${reviewFiles}\n${read('skills-review/submitted-files.js')}`.includes(token)) throw new Error(`missing review file manifest ${token}`);
|
for (const token of ['ndo-repro','gfiber-logging','confluence-page','diagram-plantuml','page-reviewer','unslop','spanish-naturalizer','draft-mr','semantic-diff-review','reference.md','files =']) if (!`${reviewFiles}\n${read('skills-review/submitted-files.js')}`.includes(token)) throw new Error(`missing review file manifest ${token}`);
|
||||||
if ((reviewCatalog.match(/id:'/g) || []).length + (read('skills-review/submitted-catalog.js').match(/id:'/g) || []).length !== 24) throw new Error('review catalog does not cover all submissions');
|
if ((reviewCatalog.match(/id:'/g) || []).length + (read('skills-review/submitted-catalog.js').match(/id:'/g) || []).length !== 24) throw new Error('review catalog does not cover all submissions');
|
||||||
@@ -82,3 +84,8 @@ for (const token of ['.change-lens','.change-rows','.skill-diff','.diff-lines','
|
|||||||
console.log('review change-lens verification passed');
|
console.log('review change-lens verification passed');
|
||||||
for (const token of ['.markdown-preview','max-height:540px','.markdown-table-wrap','.markdown-frontmatter','.markdown-toc','.preview-title','.preview-markdown','grid-template-columns:minmax(0,1fr)','height:120px','-webkit-line-clamp:2']) if (!read('skills-review/styles.css').includes(token)) throw new Error(`review markdown preview contract missing ${token}`);
|
for (const token of ['.markdown-preview','max-height:540px','.markdown-table-wrap','.markdown-frontmatter','.markdown-toc','.preview-title','.preview-markdown','grid-template-columns:minmax(0,1fr)','height:120px','-webkit-line-clamp:2']) if (!read('skills-review/styles.css').includes(token)) throw new Error(`review markdown preview contract missing ${token}`);
|
||||||
console.log('review markdown preview verification passed');
|
console.log('review markdown preview verification passed');
|
||||||
|
for (const token of ["from './vote.js'","renderVoteWidget($('#vote-widget'"]) if (!reviewJs.includes(token)) throw new Error(`review vote widget wiring missing ${token}`);
|
||||||
|
for (const token of ['id="vote-widget"','function renderVoteWidget','X-Voter-Id','/api/votes','Voting is offline']) if (!reviewVoteJs.includes(token) && !reviewJs.includes(token)) throw new Error(`review vote widget contract missing ${token}`);
|
||||||
|
for (const token of ['.vote-widget','.vote-buttons','[aria-pressed="true"]']) if (!read('skills-review/styles.css').includes(token)) throw new Error(`review vote widget CSS missing ${token}`);
|
||||||
|
if (!voteService.includes('X-Forwarded-For') || !voteService.includes('one active vote per skill') && !voteService.includes('at most one active vote')) throw new Error('vote-service missing IP-based one-vote-per-source contract');
|
||||||
|
console.log('review vote widget verification passed');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { catalog } from './catalog.js';
|
import { catalog } from './catalog.js';
|
||||||
import { files } from './files.js';
|
import { files } from './files.js';
|
||||||
|
import { renderVoteWidget } from './vote.js';
|
||||||
|
|
||||||
const state = { selected: catalog[0], query: '', preview: 'original', file: null, sourceByPath: new Map(), lens: false, rendered: false, diff: false, searching: false, contentMatches: new Set(), searchTimer: null, searchRequest: 0 };
|
const state = { selected: catalog[0], query: '', preview: 'original', file: null, sourceByPath: new Map(), lens: false, rendered: false, diff: false, searching: false, contentMatches: new Set(), searchTimer: null, searchRequest: 0 };
|
||||||
const $ = (selector) => document.querySelector(selector);
|
const $ = (selector) => document.querySelector(selector);
|
||||||
@@ -158,7 +159,8 @@ function previewMarkup(entry, available) {
|
|||||||
}
|
}
|
||||||
function renderDetail() {
|
function renderDetail() {
|
||||||
const entry = state.selected; const available = packageFiles(entry);
|
const entry = state.selected; const available = packageFiles(entry);
|
||||||
$('#detail').innerHTML = `<header><div><span class="status">${escape(entry.status)}</span><h2>${escape(entry.title)}</h2><p>Submitted by <a class="author-link" href="?author=${encodeURIComponent(entry.author)}">${escape(entry.author)}</a> · <a class="share-link" href="?author=${encodeURIComponent(entry.author)}&skill=${encodeURIComponent(entry.id)}&view=${state.preview}">share review ↗</a></p></div><div class="switch" role="group" aria-label="Preview version"><button class="${state.preview === 'original' ? 'active' : ''}" data-preview="original">Original</button><button class="${state.preview === 'improved' ? 'active' : ''}" data-preview="improved">Improved draft</button></div></header><div class="purpose"><span>THE JOB</span><p>${escape(entry.focus)}</p></div><div class="review-grid"><section><span>WHAT'S ALREADY WORKING</span><ul>${entry.wins.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section><section><span>HIGHEST-VALUE IMPROVEMENTS</span><ul>${entry.improve.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section></div><aside class="extras"><span>GOOD NEXT ADDITION</span><p>${escape(entry.extras)}</p></aside>${previewMarkup(entry, available)}`;
|
$('#detail').innerHTML = `<header><div><span class="status">${escape(entry.status)}</span><h2>${escape(entry.title)}</h2><p>Submitted by <a class="author-link" href="?author=${encodeURIComponent(entry.author)}">${escape(entry.author)}</a> · <a class="share-link" href="?author=${encodeURIComponent(entry.author)}&skill=${encodeURIComponent(entry.id)}&view=${state.preview}">share review ↗</a></p></div><div class="switch" role="group" aria-label="Preview version"><button class="${state.preview === 'original' ? 'active' : ''}" data-preview="original">Original</button><button class="${state.preview === 'improved' ? 'active' : ''}" data-preview="improved">Improved draft</button></div></header><div class="purpose"><span>THE JOB</span><p>${escape(entry.focus)}</p></div><div id="vote-widget"></div><div class="review-grid"><section><span>WHAT'S ALREADY WORKING</span><ul>${entry.wins.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section><section><span>HIGHEST-VALUE IMPROVEMENTS</span><ul>${entry.improve.map((item) => `<li>${escape(item)}</li>`).join('')}</ul></section></div><aside class="extras"><span>GOOD NEXT ADDITION</span><p>${escape(entry.extras)}</p></aside>${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-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-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(); }));
|
$('#detail').querySelectorAll('[data-lens]').forEach((button) => button.addEventListener('click', () => { state.lens = !state.lens; state.rendered = false; state.diff = false; syncUrl(); renderDetail(); }));
|
||||||
|
|||||||
@@ -6,8 +6,10 @@
|
|||||||
<meta name="description" content="Friendly reviews and improved drafts for submitted Agent Skills." />
|
<meta name="description" content="Friendly reviews and improved drafts for submitted Agent Skills." />
|
||||||
<title>Submitted Skills — Review Desk</title>
|
<title>Submitted Skills — Review Desk</title>
|
||||||
<!-- Bump all review asset versions together when this interface changes. -->
|
<!-- Bump all review asset versions together when this interface changes. -->
|
||||||
<link rel="stylesheet" href="styles.css?v=20260904-preview-toolbar" />
|
<link rel="stylesheet" href="styles.css?v=20260904-vote-widget" />
|
||||||
<link rel="stylesheet" href="change-lens.css?v=20260904-preview-toolbar" />
|
<link rel="stylesheet" href="change-lens.css?v=20260904-vote-widget" />
|
||||||
|
<!-- Vote API origin: set after vote-service is deployed (see /vote-service). Empty = widget shows "voting offline". -->
|
||||||
|
<script>window.SKILLS_REVIEW_VOTE_API = 'https://ai-for-dummies-vote.marcospaulo.dev.br';</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
@@ -25,7 +27,7 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="method">
|
<section class="method">
|
||||||
<div><p class="eyebrow">How to use this desk</p><h2>Compare.<br><em>Then choose.</em></h2></div>
|
<div><p class="eyebrow">How to use this desk</p><h2>Compare.<br><em>Then choose.</em></h2></div>
|
||||||
<ol><li>Select a submission, or open an author URL.</li><li>Read the gentle review before judging the draft.</li><li>Choose <strong>Preview Markdown</strong> in the file toolbar to render either version.</li><li>Copy or download the version you want.</li></ol>
|
<ol><li>Select a submission, or open an author URL.</li><li>Read the gentle review before judging the draft.</li><li>Choose <strong>Preview Markdown</strong> in the file toolbar to render either version.</li><li>Copy or download the version you want, then vote for the draft you would ship.</li></ol>
|
||||||
</section>
|
</section>
|
||||||
<section class="catalog" id="catalog">
|
<section class="catalog" id="catalog">
|
||||||
<aside><p class="eyebrow">The catalog</p><label for="skill-filter">Find a skill</label><input id="skill-filter" type="search" placeholder="author, skill, topic" autocomplete="off"><p class="count" id="count"></p><div id="skill-list" role="listbox" aria-label="Submitted skills"></div></aside>
|
<aside><p class="eyebrow">The catalog</p><label for="skill-filter">Find a skill</label><input id="skill-filter" type="search" placeholder="author, skill, topic" autocomplete="off"><p class="count" id="count"></p><div id="skill-list" role="listbox" aria-label="Submitted skills"></div></aside>
|
||||||
@@ -36,8 +38,8 @@
|
|||||||
<p>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.</p>
|
<p>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.</p>
|
||||||
<div><a href="https://agentskills.io/specification" target="_blank" rel="noreferrer">Format specification ↗</a><a href="https://agentskills.io/skill-creation/best-practices" target="_blank" rel="noreferrer">Writing practices ↗</a><a href="https://agentskills.io/skill-creation/evaluating-skills" target="_blank" rel="noreferrer">Evaluation loop ↗</a><a href="https://agentskills.io/skill-creation/using-scripts" target="_blank" rel="noreferrer">Scripts guide ↗</a></div>
|
<div><a href="https://agentskills.io/specification" target="_blank" rel="noreferrer">Format specification ↗</a><a href="https://agentskills.io/skill-creation/best-practices" target="_blank" rel="noreferrer">Writing practices ↗</a><a href="https://agentskills.io/skill-creation/evaluating-skills" target="_blank" rel="noreferrer">Evaluation loop ↗</a><a href="https://agentskills.io/skill-creation/using-scripts" target="_blank" rel="noreferrer">Scripts guide ↗</a></div>
|
||||||
</section>
|
</section>
|
||||||
<footer>Share an author with <code>?author=Name</code>, or one review with <code>?author=Name&skill=skill-id&view=improved</code>. To add a submission later: drop a package under <code>submitted-skills/</code>, add a tailored entry in <code>skills-review/catalog.js</code>, then run <code>node scripts/build-skill-review.mjs</code>.</footer>
|
<footer>Share an author with <code>?author=Name</code>, or one review with <code>?author=Name&skill=skill-id&view=improved</code>. To add a submission later: drop a package under <code>submitted-skills/</code>, add a tailored entry in <code>skills-review/catalog.js</code>, then run <code>node scripts/build-skill-review.mjs</code>. Votes call a separate service — see <code>vote-service/</code> — one per visitor, tracked by network source.</footer>
|
||||||
</main>
|
</main>
|
||||||
<script type="module" src="app.js?v=20260904-preview-toolbar"></script>
|
<script type="module" src="app.js?v=20260904-vote-widget"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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 `<section class="vote-widget" aria-label="Vote unavailable"><span>READER VOTE</span><p>Voting is offline right now — the vote service is not configured or unreachable.</p></section>`;
|
||||||
|
return `<section class="vote-widget" aria-label="Vote on this review" data-skill="${escape(skillId)}">
|
||||||
|
<span>WHICH DRAFT WOULD YOU SHIP?</span>
|
||||||
|
<div class="vote-buttons" role="group" aria-label="Cast your vote">
|
||||||
|
<button data-vote="original" aria-pressed="${you === 'original'}">Original<b>${tally.original || 0} · ${share(tally.original || 0)}%</b></button>
|
||||||
|
<button data-vote="improved" aria-pressed="${you === 'improved'}">Improved draft<b>${tally.improved || 0} · ${share(tally.improved || 0)}%</b></button>
|
||||||
|
</div>
|
||||||
|
<p class="vote-note">${you ? `You voted ${you === 'original' ? 'original' : 'improved draft'}. Pick the other option to change it.` : 'One vote per visitor, tracked by network source.'}</p>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
@@ -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": { "<skillId>": { "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 <CF_TOKEN>
|
||||||
|
}
|
||||||
|
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.
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/marcospaulo/ai-for-dummies/vote-service
|
||||||
|
|
||||||
|
go 1.22
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user