From 44e9faa190beb78ec17d891e26c28cd86c159402 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 21:48:04 +0000 Subject: [PATCH] =?UTF-8?q?XP=20kudos:=20a=20live=20=F0=9F=91=8F=20count?= =?UTF-8?q?=20on=20each=20plain-English=20XP=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /api/kudos (netlify/functions/kudos.mjs) keeps the counts as one JSON document in the "kudos" Netlify Blobs store, separate from site data. Known entries only, once per visitor per entry per day (an HMAC of IP, entry and day written with onlyIfNew; no IP stored), same-origin only, and ETag-checked writes so simultaneous kudos all count - The XP data moves to data/xp.mjs so the function and the card share it; xpId gives each entry a stable id from the fields that name it - composables/useKudos.js refreshes every 15s while the plain-English XP list is showing; buttons hide where the function isn't running - The service worker leaves /api/kudos alone (never a stale count) - Security tests with an in-memory store, hostile bodies in payloads.mjs, and an accessibility test of the buttons - The yoga teacher training line says "Graduate" (same as #261) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Nginuw5wmUjUSkmQB1J3pS --- README.md | 4 +- components/AboutCard.vue | 186 ++++---------- composables/useKudos.js | 78 ++++++ data/xp.mjs | 147 +++++++++++ netlify/functions/kudos.mjs | 86 +++++++ package-lock.json | 451 +++++++++++++++++++++++++++++++++- package.json | 1 + public/sw.js | 4 +- tests/e2e/a11y.spec.mjs | 31 +++ tests/security/kudos.test.mjs | 94 +++++++ tests/security/payloads.mjs | 22 ++ 11 files changed, 968 insertions(+), 136 deletions(-) create mode 100644 composables/useKudos.js create mode 100644 data/xp.mjs create mode 100644 netlify/functions/kudos.mjs create mode 100644 tests/security/kudos.test.mjs diff --git a/README.md b/README.md index b68bf4a..9dcf356 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,9 @@ When a press-and-hold on the coin lands it heads up (the profile picture), a blu It shows through the end of `until` (Eastern time), then switches itself off without a redeploy. `npm run build:coin-bubble` (before tests and dev) writes `data/coinBubble.generated.mjs`, with the QR already turned into an SVG path (`utils/coinQr.mjs`, using `uqr`), and fails the build on a bad setting (`utils/coinBubble.mjs`). ## About card (home page) -`components/AboutCard.vue` holds the Skills, Hobbies and XP tabs. XP is a timeline by year: a slider from 2014 to now shows each year's education, work, volunteer, career-break and achievement entries, with a category icon for each. Swipe left or right across the card to move through the years (a swipe left goes to later years). The mouse wheel works too while the pointer is over the card, or while a keyboard or controller has focus in it: sideways or down goes to later years, and at the first or last year the page scrolls on. While the card is in use the social links below pause (they move as the card changes size): on phones until the card is scrolled away or you tap elsewhere (a tap on a paused link just unpauses it), with a mouse for a second after the press. The data lives in the component's `education`, `experience` and `achievements` arrays. Hold the card (or alt-click on desktop) for plain English. In plain English, visitors whose browser prefers another language get a small link to read it in Google Translate (`utils/translate.js`); English readers don't see it. A link to `/#xp` (the Bio page has one) opens the card on XP (`utils/aboutCard.js`). +`components/AboutCard.vue` holds the Skills, Hobbies and XP tabs. XP is a timeline by year: a slider from 2014 to now shows each year's education, work, volunteer, career-break and achievement entries, with a category icon for each. Swipe left or right across the card to move through the years (a swipe left goes to later years). The mouse wheel works too while the pointer is over the card, or while a keyboard or controller has focus in it: sideways or down goes to later years, and at the first or last year the page scrolls on. While the card is in use the social links below pause (they move as the card changes size): on phones until the card is scrolled away or you tap elsewhere (a tap on a paused link just unpauses it), with a mouse for a second after the press. The data lives in `data/xp.mjs` (`education`, `experience` and `achievements`). Hold the card (or alt-click on desktop) for plain English. In plain English, visitors whose browser prefers another language get a small link to read it in Google Translate (`utils/translate.js`); English readers don't see it. A link to `/#xp` (the Bio page has one) opens the card on XP (`utils/aboutCard.js`). + +Kudos: in plain English, each XP entry has a ๐Ÿ‘ button with a live count shared by every visitor (`composables/useKudos.js`, refreshed every 15 seconds while the list is showing). The `/api/kudos` Netlify function (`netlify/functions/kudos.mjs`) keeps the counts as one JSON document in a Netlify Blobs store named `kudos`, apart from the site's data, keyed by each entry's id (`xpId` in `data/xp.mjs`, made from the fields that name the entry, so rewording a line keeps its kudos). It counts only known entries, once per visitor per entry per day (by a hash of their IP address, which isn't stored), and only from the site's own pages. Nothing to set up: Netlify provides the store. Where the function isn't running (a local preview), the buttons don't show. The social links under the card are in `components/Socials.vue`. Tapping Instagram opens a small menu with both accounts: hansel.rocks (main) and hansel.codes (code and software engineering only). A pill gets a menu from its `choices` list (`components/SocialFlipLink.vue`). The addresses are in `utils/outbound.js`. On phones and tablets each link opens a new tab through `/out/` (`pages/out/[id].vue`), which goes on to the link; when the phone hands the link to an app instead, that tab closes itself once you come back, so no blank tab is left behind. diff --git a/components/AboutCard.vue b/components/AboutCard.vue index 3635642..bfce6c9 100644 --- a/components/AboutCard.vue +++ b/components/AboutCard.vue @@ -113,6 +113,20 @@ Entry {{ i + 1 }} of {{ yearEntries.length }}, {{ entry.category }}: {{ entry.label }} ยท {{ entry.text }} + +

{{ yearJson }}

@@ -149,6 +163,8 @@ import { languageName, translateTarget, translateUrl } from '~/utils/translate.j import { swipeIntent, swipeYears, WHEEL_STEP_PX, wheelDx, wheelDy } from '~/utils/swipe.js' import { isActionTarget } from '~/utils/actionTarget.js' import { opensXp } from '~/utils/aboutCard.js' +import { achievements, education, experience, xpId } from '~/data/xp.mjs' +import { useKudos } from '~/composables/useKudos.js' // One card under the contact button: the token's latest fact, or the "I am a" // (skills), hobbies and education JSON, switched with the toggle on top. @@ -194,139 +210,6 @@ const hobbies = [ const hobbyWords = { Maker: 'making things', 'Stained Glass': 'crafting stained glass' } const hobbyPhrase = hobby => hobbyWords[hobby] ?? hobby.toLowerCase() -// XP: certifications and education, then work history (newest first). `readable` and -// `highlight` are the plain-English text; the JSON view uses the fields only. -const education = [ - // The AMGA SPI certification is the course plus a separate exam: only the course is done - { - category: 'Education', - program: 'AMGA Single Pitch Instructor (SPI) Course', - passed: 'May 2026', - status: 'Course passed; SPI exam not yet taken (certification needs both)', - school: 'Pisgah Climbing School', - location: 'Brevard, NC', - date: 'Passed May 2026', - readable: 'AMGA Single Pitch Instructor (SPI) Course with Pisgah Climbing School in Brevard, NC. The SPI certification also needs the separate SPI exam, which I haven\'t taken yet.' - }, - { - category: 'Certification', - certification: 'Data Science and Machine Learning: Making Data-Driven Decisions', - passed: 'September 2024', - school: 'MIT Institute for Data, Systems, and Society (IDSS)', - location: 'Online', - date: 'Passed September 2024', - readable: 'Certification: Data Science and Machine Learning: Making Data-Driven Decisions from MIT IDSS.' - }, - { - category: 'Education', - graduated: 2025, - program: 'YTT200', - school: 'Chakra Flow University', - location: 'Charlotte, NC', - date: '2025', - readable: '200-hour Yoga Teacher Training with Chakra Flow University at the Whitewater Center in Charlotte, NC.' - }, - { - category: 'Education', - started: 2016, - graduated: 2019, - program: 'Computer Science, BS / Communication Studies Minor', - school: 'UNC Charlotte', - location: 'Charlotte, NC', - date: '2016 โ€“ 2019', - readable: 'Bachelor of Science in Computer Science with a minor in Communication Studies from UNC Charlotte in Charlotte, NC.' - } -] - -const experience = [ - { category: 'Work', role: 'Senior Software Engineer', company: 'Wells Fargo', start: 'Sep 2025', end: 'Present', location: 'Charlotte, NC', highlight: 'Engineer, develop and maintain GitHub Actions, ARC, Workflows and Kubernetes enterprise-wide platform solutions; lead projects and act as an escalation point for less experienced staff' }, - { category: 'Work', role: 'Software Engineer 4', company: 'Apex Systems (Wells Fargo)', start: 'Jun 2025', end: 'Sep 2025', location: 'Hybrid', highlight: 'Engineered language-specific GitHub Actions support across Python, Ruby, C#, Node.js and Java; built GitHub-hosted runners on Azure VNet and customized self-hosted runners for TKGI and OpenShift' }, - { category: 'Volunteer', role: 'Voluntary work', company: 'Project H.E.R.O., Jubilee House Community', start: 'Jan 2025', end: 'May 2025', location: 'Remote', highlight: 'Founding project manager and lead maintainer of an open-source naloxone vending machine project' }, - { category: 'Work', role: 'Senior Software Engineer', company: 'Brooksource (Ally Financial)', start: 'Jun 2024', end: 'Dec 2024', location: 'Charlotte, NC, hybrid', highlight: 'GenAI and RAG for audit, compliance and risk; migrated a React app to Vite (95% faster build and runtime); AWS and Terraform infrastructure; Technology Achievement Award for a 25% cut in audit processing time' }, - { category: 'Career break', role: 'Career break', start: 'Apr 2024', end: 'Jun 2024', highlight: 'Position eliminated' }, - { category: 'Work', role: 'Senior Software Developer', company: 'TIAA', start: 'Mar 2022', end: 'Apr 2024', location: 'United States', highlight: 'Founded the Developer Experience Guild; platform SME across the enterprise; L3 platform support and continuous improvement on OpenShift, Kong and CI/CD' }, - { category: 'Work', role: 'Developer II', company: 'TIAA', start: 'Jan 2021', end: 'Mar 2022', location: 'Charlotte, NC', highlight: 'Full-stack development and solution architecture for imaging, data distribution and workflow systems' }, - { category: 'Work', role: 'Developer', company: 'TIAA', start: 'Jan 2020', end: 'Jan 2021', location: 'Charlotte, NC', highlight: 'Full-stack development on the TIAA Robotics Platform and Imaging Distribution; reverse-engineered and refactored legacy monoliths into microservices with Spring Boot and OpenShift' }, - { category: 'Work', role: 'Full Stack Developer, Robotics Platform', company: 'TIAA (Brooksource)', start: 'Jul 2019', end: 'Jan 2020', location: 'Charlotte, NC', highlight: 'Automation Anywhere RPA bots and process intelligence systems; LSTM text classification model; cognitive services platform for text, voice and object detection' }, - { category: 'Work', role: 'Computer Scientist (Research and Development)', company: 'National Gypsum Company', start: 'May 2019', end: 'Jul 2019', location: 'Charlotte, NC', highlight: 'Azure Machine Learning deployment for material science; migrated ensemble ML from Python to C#/.NET; UX research and an analytics dashboard' }, - { category: 'Work', role: 'Computer Science Intern (Research and Development)', company: 'National Gypsum Company', start: 'Jan 2019', end: 'May 2019', location: 'Charlotte Metro', highlight: 'Partnered with Microsoft engineers on Azure AI data modeling and data science pipelines for real-time and batch processing; prototyped Azure ML environments; built a Power BI visualization and a data analytics dashboard proof of concept' }, - { category: 'Work', role: 'College of Computing and Informatics Tutor', company: 'UNC Charlotte', start: 'Jan 2017', end: 'May 2019', location: 'Charlotte, NC', highlight: 'One-on-one tutoring for 10+ undergrad and grad CS students in Java, C, data structures, machine learning, AWS and networking; won HackNC 2018 (Major League Hacking\'s 2019 season); gave over a dozen workshops at hackathons and campus events' }, - { category: 'Volunteer', role: 'Guest speaker, GenCyber camp', company: 'Winthrop University, Richard W. Riley College of Education', start: '2017', end: '2017', location: 'Rock Hill, SC', highlight: 'Demonstrated open wireless network insecurity and how to ethically hack a network for teachers and students, as a student and computer science peer tutor at UNC Charlotte' }, - { category: 'Work', role: 'Associate', company: 'Carowinds', start: 'Apr 2016', end: 'Oct 2016', location: 'Charlotte Metro', highlight: 'Cashier supervisor responsible for handling money; supervised a team and improved customer satisfaction and accountability' }, - { category: 'Work', role: 'Computer Repair Technician / Network Designer (self-employed)', start: 'Jul 2014', end: 'Jul 2017', highlight: 'Remote support by phone, email and TeamViewer; on-site hardware upgrades and FRU repairs; network, server and security design for small businesses' } -] - -// Achievements: milestones on the XP timeline (the medal). `date` is when it happened; MLH -// hackathons also carry their Major League Hacking season, which runs from late summer to early -// summer and is named for the year it ends (fall 2018 events are the 2019 season) -const achievements = [ - { - category: 'Achievement', - event: 'MLH: hackathonCLT MMXIX', - season: 'MLH 2019 season', - place: 'Submitted', - date: '2019', - location: 'Charlotte, NC', - readable: 'Submitted a project to hackathonCLT MMXIX (MLH 2019 season) in Charlotte, NC (March 2019).' - }, - { - category: 'Achievement', - event: 'MLH: HackDuke 2018: Code for Good', - season: 'MLH 2019 season', - place: 'Submitted', - date: '2018', - location: 'Durham, NC', - readable: 'Submitted a project to HackDuke 2018: Code for Good (MLH 2019 season) in Durham, NC (October 2018).' - }, - { - category: 'Achievement', - event: 'MLH: HackNC 2018', - season: 'MLH 2019 season', - place: 'Winner', - project: 'Human Trafficking Textual and Emoji Analysis', - description: 'Feature generation and emoji text analysis, for machine learning, to help prevent human trafficking.', - date: '2018', - location: 'Chapel Hill, NC', - readable: 'Winner at HackNC 2018 (MLH 2019 season) in Chapel Hill, NC (October 2018) with Human Trafficking Textual and Emoji Analysis: feature generation and emoji text analysis to help prevent human trafficking.' - }, - { - category: 'Achievement', - event: 'MLH: HackDuke 2017: Code for Good', - season: 'MLH 2018 season', - place: 'Submitted', - date: '2017', - location: 'Durham, NC', - readable: 'Submitted a project to HackDuke 2017: Code for Good (MLH 2018 season) in Durham, NC (October 2017).' - }, - { - category: 'Achievement', - event: 'MLH: RamHacks 2017', - season: 'MLH 2018 season', - place: 'Submitted', - project: 'ramhacksfall2017', - date: '2017', - location: 'Richmond, VA', - readable: 'Submitted ramhacksfall2017 to RamHacks 2017 (MLH 2018 season) in Richmond, VA (September 2017).' - }, - { - category: 'Achievement', - event: 'Collegiate Cyber Defense Competition (CCDC), Southeast Regional Finals', - place: 'Fourth at Kennesaw State University (UNC Charlotte team)', - date: '2018', - location: 'Kennesaw, GA', - readable: 'Fourth place: UNC Charlotte team at the Southeast Regional Finals of the Collegiate Cyber Defense Competition (CCDC) at Kennesaw State University (2018).' - }, - { - category: 'Achievement', - event: 'Around the Crown 10K', - distance: '10K', - first: true, - date: 'Sep 2026', - location: 'Charlotte, NC', - readable: 'First 10K: Around the Crown 10K in Charlotte, NC.' - } -] - // Valid JSON on one line, like the other About views, built from the fields so quoting is right const jsonValue = value => { if (Array.isArray(value)) return `[ ${value.map(jsonValue).join(', ')} ]` @@ -356,6 +239,8 @@ const timeline = [ ...education.map(entry => { const to = yearOf(entry.graduated ?? entry.passed) return { + id: xpId(entry), + name: entry.program === 'YTT200' ? 'Yoga Teacher Training' : entry.program ?? entry.certification, from: entry.started ?? to, to, label: entry.date, @@ -365,6 +250,8 @@ const timeline = [ } }), ...experience.map(job => ({ + id: xpId(job), + name: job.company ? `${job.role} at ${job.company}` : job.role, from: yearOf(job.start), to: job.end === 'Present' ? CURRENT_YEAR : yearOf(job.end), label: job.start === job.end ? job.start : `${job.start} โ€“ ${job.end}`, @@ -373,6 +260,8 @@ const timeline = [ json: `{ "experience" : ${jsonValue(job.highlight ? job : { ...job, highlight: undefined })} }` })), ...achievements.map(item => ({ + id: xpId(item), + name: item.event, from: yearOf(item.date), to: yearOf(item.date), label: item.date, @@ -490,6 +379,9 @@ const HOLD_MS = 900 const HOLD_SLOP_PX = 10 // moving further than this (e.g. scrolling) cancels the hold const TAP_MAX_MS = 300 // a press longer than this that lets go early is an abandoned hold, not a tap const readable = ref(false) +// Kudos on each plain-English XP entry, live while that list is on screen +const kudos = useKudos() +kudos.watchActive(() => view.value === 'edu' && readable.value) const charging = ref(false) const transitionName = ref('swap') const cardEl = ref(null) @@ -1029,6 +921,34 @@ cite { line-height: 1.55; } +.kudos { + display: inline-flex; + align-items: center; + min-height: 24px; + min-width: 24px; + margin-left: 0.4rem; + padding: 0 0.5rem; + border: 1px solid #475569; + border-radius: 999px; + background: transparent; + color: #e2e8f0; + font-size: 0.75rem; + line-height: 1.4; + white-space: nowrap; + cursor: pointer; + touch-action: manipulation; +} + +.kudos:hover, +.kudos:focus-visible { + border-color: #94a3b8; +} + +.kudos--given { + border-color: #22c55e; + cursor: default; +} + .edu-list li + li { margin-top: 0.45rem; } diff --git a/composables/useKudos.js b/composables/useKudos.js new file mode 100644 index 0000000..e7fd98c --- /dev/null +++ b/composables/useKudos.js @@ -0,0 +1,78 @@ +// Kudos for the About card's XP entries (netlify/functions/kudos.mjs). Counts are shared by +// everyone and refreshed while the list is on screen; which entries this browser has already +// given kudos to is remembered locally, so the button shows it. Where the function isn't there +// (local preview), `available` stays false and the buttons don't show. +const GIVEN_KEY = 'kudos:given' +const REFRESH_MS = 15_000 + +const readGiven = () => { + try { + const list = JSON.parse(localStorage.getItem(GIVEN_KEY) ?? '[]') + return new Set(Array.isArray(list) ? list.filter(id => typeof id === 'string') : []) + } catch { + return new Set() + } +} +const saveGiven = given => { + try { + localStorage.setItem(GIVEN_KEY, JSON.stringify([...given])) + } catch {} +} + +export const useKudos = () => { + const available = ref(false) + const counts = ref({}) + const given = ref(new Set()) + let timer = null + + const refresh = async () => { + try { + const res = await fetch('/api/kudos', { headers: { accept: 'application/json' } }) + const body = res.ok && res.headers.get('content-type')?.includes('application/json') ? await res.json() : null + if (!body?.counts || typeof body.counts !== 'object') return + counts.value = body.counts + available.value = true + } catch {} + } + + const give = async id => { + if (given.value.has(id)) return + const before = counts.value[id] ?? 0 + counts.value = { ...counts.value, [id]: before + 1 } + given.value = new Set([...given.value, id]) + try { + const res = await fetch('/api/kudos', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }) + if (!res.ok) throw new Error(String(res.status)) + const body = await res.json() + if (Number.isSafeInteger(body.count)) counts.value = { ...counts.value, [id]: body.count } + saveGiven(given.value) + } catch { + counts.value = { ...counts.value, [id]: before } + const rest = new Set(given.value) + rest.delete(id) + given.value = rest + } + } + + const tick = () => { + if (!document.hidden) refresh() + } + // Live while `active` (the plain-English XP list is showing) + const watchActive = active => { + watch( + active, + on => { + clearInterval(timer) + timer = null + if (!on || import.meta.server) return + given.value = readGiven() + refresh() + timer = setInterval(tick, REFRESH_MS) + }, + { immediate: true } + ) + onBeforeUnmount(() => clearInterval(timer)) + } + + return { available, counts, given, give, watchActive } +} diff --git a/data/xp.mjs b/data/xp.mjs new file mode 100644 index 0000000..85e8ae3 --- /dev/null +++ b/data/xp.mjs @@ -0,0 +1,147 @@ +// The About card's XP timeline (components/AboutCard.vue): certifications and education, work +// history and achievements. Shared with the kudos function (netlify/functions/kudos.mjs), which +// only counts kudos for an entry listed here. + +// XP: certifications and education, then work history (newest first). `readable` and +// `highlight` are the plain-English text; the JSON view uses the fields only. +export const education = [ + // The AMGA SPI certification is the course plus a separate exam: only the course is done + { + category: 'Education', + program: 'AMGA Single Pitch Instructor (SPI) Course', + passed: 'May 2026', + status: 'Course passed; SPI exam not yet taken (certification needs both)', + school: 'Pisgah Climbing School', + location: 'Brevard, NC', + date: 'Passed May 2026', + readable: 'AMGA Single Pitch Instructor (SPI) Course with Pisgah Climbing School in Brevard, NC. The SPI certification also needs the separate SPI exam, which I haven\'t taken yet.' + }, + { + category: 'Certification', + certification: 'Data Science and Machine Learning: Making Data-Driven Decisions', + passed: 'September 2024', + school: 'MIT Institute for Data, Systems, and Society (IDSS)', + location: 'Online', + date: 'Passed September 2024', + readable: 'Certification: Data Science and Machine Learning: Making Data-Driven Decisions from MIT IDSS.' + }, + { + category: 'Education', + graduated: 2025, + program: 'YTT200', + school: 'Chakra Flow University', + location: 'Charlotte, NC', + date: '2025', + readable: '200-hour Yoga Teacher Training Graduate from Chakra Flow University at the Whitewater Center in Charlotte, NC.' + }, + { + category: 'Education', + started: 2016, + graduated: 2019, + program: 'Computer Science, BS / Communication Studies Minor', + school: 'UNC Charlotte', + location: 'Charlotte, NC', + date: '2016 โ€“ 2019', + readable: 'Bachelor of Science in Computer Science with a minor in Communication Studies from UNC Charlotte in Charlotte, NC.' + } +] + +export const experience = [ + { category: 'Work', role: 'Senior Software Engineer', company: 'Wells Fargo', start: 'Sep 2025', end: 'Present', location: 'Charlotte, NC', highlight: 'Engineer, develop and maintain GitHub Actions, ARC, Workflows and Kubernetes enterprise-wide platform solutions; lead projects and act as an escalation point for less experienced staff' }, + { category: 'Work', role: 'Software Engineer 4', company: 'Apex Systems (Wells Fargo)', start: 'Jun 2025', end: 'Sep 2025', location: 'Hybrid', highlight: 'Engineered language-specific GitHub Actions support across Python, Ruby, C#, Node.js and Java; built GitHub-hosted runners on Azure VNet and customized self-hosted runners for TKGI and OpenShift' }, + { category: 'Volunteer', role: 'Voluntary work', company: 'Project H.E.R.O., Jubilee House Community', start: 'Jan 2025', end: 'May 2025', location: 'Remote', highlight: 'Founding project manager and lead maintainer of an open-source naloxone vending machine project' }, + { category: 'Work', role: 'Senior Software Engineer', company: 'Brooksource (Ally Financial)', start: 'Jun 2024', end: 'Dec 2024', location: 'Charlotte, NC, hybrid', highlight: 'GenAI and RAG for audit, compliance and risk; migrated a React app to Vite (95% faster build and runtime); AWS and Terraform infrastructure; Technology Achievement Award for a 25% cut in audit processing time' }, + { category: 'Career break', role: 'Career break', start: 'Apr 2024', end: 'Jun 2024', highlight: 'Position eliminated' }, + { category: 'Work', role: 'Senior Software Developer', company: 'TIAA', start: 'Mar 2022', end: 'Apr 2024', location: 'United States', highlight: 'Founded the Developer Experience Guild; platform SME across the enterprise; L3 platform support and continuous improvement on OpenShift, Kong and CI/CD' }, + { category: 'Work', role: 'Developer II', company: 'TIAA', start: 'Jan 2021', end: 'Mar 2022', location: 'Charlotte, NC', highlight: 'Full-stack development and solution architecture for imaging, data distribution and workflow systems' }, + { category: 'Work', role: 'Developer', company: 'TIAA', start: 'Jan 2020', end: 'Jan 2021', location: 'Charlotte, NC', highlight: 'Full-stack development on the TIAA Robotics Platform and Imaging Distribution; reverse-engineered and refactored legacy monoliths into microservices with Spring Boot and OpenShift' }, + { category: 'Work', role: 'Full Stack Developer, Robotics Platform', company: 'TIAA (Brooksource)', start: 'Jul 2019', end: 'Jan 2020', location: 'Charlotte, NC', highlight: 'Automation Anywhere RPA bots and process intelligence systems; LSTM text classification model; cognitive services platform for text, voice and object detection' }, + { category: 'Work', role: 'Computer Scientist (Research and Development)', company: 'National Gypsum Company', start: 'May 2019', end: 'Jul 2019', location: 'Charlotte, NC', highlight: 'Azure Machine Learning deployment for material science; migrated ensemble ML from Python to C#/.NET; UX research and an analytics dashboard' }, + { category: 'Work', role: 'Computer Science Intern (Research and Development)', company: 'National Gypsum Company', start: 'Jan 2019', end: 'May 2019', location: 'Charlotte Metro', highlight: 'Partnered with Microsoft engineers on Azure AI data modeling and data science pipelines for real-time and batch processing; prototyped Azure ML environments; built a Power BI visualization and a data analytics dashboard proof of concept' }, + { category: 'Work', role: 'College of Computing and Informatics Tutor', company: 'UNC Charlotte', start: 'Jan 2017', end: 'May 2019', location: 'Charlotte, NC', highlight: 'One-on-one tutoring for 10+ undergrad and grad CS students in Java, C, data structures, machine learning, AWS and networking; won HackNC 2018 (Major League Hacking\'s 2019 season); gave over a dozen workshops at hackathons and campus events' }, + { category: 'Volunteer', role: 'Guest speaker, GenCyber camp', company: 'Winthrop University, Richard W. Riley College of Education', start: '2017', end: '2017', location: 'Rock Hill, SC', highlight: 'Demonstrated open wireless network insecurity and how to ethically hack a network for teachers and students, as a student and computer science peer tutor at UNC Charlotte' }, + { category: 'Work', role: 'Associate', company: 'Carowinds', start: 'Apr 2016', end: 'Oct 2016', location: 'Charlotte Metro', highlight: 'Cashier supervisor responsible for handling money; supervised a team and improved customer satisfaction and accountability' }, + { category: 'Work', role: 'Computer Repair Technician / Network Designer (self-employed)', start: 'Jul 2014', end: 'Jul 2017', highlight: 'Remote support by phone, email and TeamViewer; on-site hardware upgrades and FRU repairs; network, server and security design for small businesses' } +] + +// Achievements: milestones on the XP timeline (the medal). `date` is when it happened; MLH +// hackathons also carry their Major League Hacking season, which runs from late summer to early +// summer and is named for the year it ends (fall 2018 events are the 2019 season) +export const achievements = [ + { + category: 'Achievement', + event: 'MLH: hackathonCLT MMXIX', + season: 'MLH 2019 season', + place: 'Submitted', + date: '2019', + location: 'Charlotte, NC', + readable: 'Submitted a project to hackathonCLT MMXIX (MLH 2019 season) in Charlotte, NC (March 2019).' + }, + { + category: 'Achievement', + event: 'MLH: HackDuke 2018: Code for Good', + season: 'MLH 2019 season', + place: 'Submitted', + date: '2018', + location: 'Durham, NC', + readable: 'Submitted a project to HackDuke 2018: Code for Good (MLH 2019 season) in Durham, NC (October 2018).' + }, + { + category: 'Achievement', + event: 'MLH: HackNC 2018', + season: 'MLH 2019 season', + place: 'Winner', + project: 'Human Trafficking Textual and Emoji Analysis', + description: 'Feature generation and emoji text analysis, for machine learning, to help prevent human trafficking.', + date: '2018', + location: 'Chapel Hill, NC', + readable: 'Winner at HackNC 2018 (MLH 2019 season) in Chapel Hill, NC (October 2018) with Human Trafficking Textual and Emoji Analysis: feature generation and emoji text analysis to help prevent human trafficking.' + }, + { + category: 'Achievement', + event: 'MLH: HackDuke 2017: Code for Good', + season: 'MLH 2018 season', + place: 'Submitted', + date: '2017', + location: 'Durham, NC', + readable: 'Submitted a project to HackDuke 2017: Code for Good (MLH 2018 season) in Durham, NC (October 2017).' + }, + { + category: 'Achievement', + event: 'MLH: RamHacks 2017', + season: 'MLH 2018 season', + place: 'Submitted', + project: 'ramhacksfall2017', + date: '2017', + location: 'Richmond, VA', + readable: 'Submitted ramhacksfall2017 to RamHacks 2017 (MLH 2018 season) in Richmond, VA (September 2017).' + }, + { + category: 'Achievement', + event: 'Collegiate Cyber Defense Competition (CCDC), Southeast Regional Finals', + place: 'Fourth at Kennesaw State University (UNC Charlotte team)', + date: '2018', + location: 'Kennesaw, GA', + readable: 'Fourth place: UNC Charlotte team at the Southeast Regional Finals of the Collegiate Cyber Defense Competition (CCDC) at Kennesaw State University (2018).' + }, + { + category: 'Achievement', + event: 'Around the Crown 10K', + distance: '10K', + first: true, + date: 'Sep 2026', + location: 'Charlotte, NC', + readable: 'First 10K: Around the Crown 10K in Charlotte, NC.' + } +] + +// A stable id for each entry, from the fields that name it (not its wording, so rewording a +// line keeps its kudos): the kudos counts are keyed by these +const slug = text => String(text).toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 120) +export const xpId = entry => { + if (entry.category === 'Achievement') return `achievement-${slug(entry.event)}` + if (entry.role) return `${entry.category === 'Volunteer' ? 'volunteer' : 'work'}-${slug(`${entry.role} ${entry.company ?? ''} ${entry.start}`)}` + return `education-${slug(entry.program ?? entry.certification)}` +} + +export const XP_IDS = new Set([...education, ...experience, ...achievements].map(xpId)) diff --git a/netlify/functions/kudos.mjs b/netlify/functions/kudos.mjs new file mode 100644 index 0000000..2fb1271 --- /dev/null +++ b/netlify/functions/kudos.mjs @@ -0,0 +1,86 @@ +// GET /api/kudos -> { counts: { [xpId]: n } }: kudos for the About card's XP entries +// POST /api/kudos { id } -> { id, count, counted }: one kudos for one entry (data/xp.mjs) +// +// The counts are one JSON document ("counts") in the "kudos" Netlify Blobs store, kept apart +// from the site's own data. Each visitor counts once per entry per day: a marker keyed by an +// HMAC of their IP address, the entry and the day (so no IP address is stored) is written with +// onlyIfNew, and only a write that creates it adds to the count. The count itself is updated +// with onlyIfMatch on its ETag and retried, so kudos sent at the same moment aren't lost. +import { createHmac } from 'node:crypto' +import { getStore } from '@netlify/blobs' +import { XP_IDS } from '../../data/xp.mjs' + +const MAX_BODY = 1024 +const MAX_TRIES = 6 +const COUNTS = 'counts' +const HEADERS = { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', 'x-robots-tag': 'noindex' } +const json = (body, status = 200, extra = {}) => new Response(JSON.stringify(body), { status, headers: { ...HEADERS, ...extra } }) + +// Only known entries, with whole non-negative counts: whatever else the stored document holds +const cleanCounts = data => { + const counts = {} + for (const id of XP_IDS) { + const n = data && typeof data === 'object' && Object.hasOwn(data, id) ? data[id] : 0 + if (Number.isSafeInteger(n) && n > 0) counts[id] = n + } + return counts +} + +const addOne = async (store, id) => { + for (let i = 0; i < MAX_TRIES; i++) { + const current = await store.getWithMetadata(COUNTS, { type: 'json' }) + const counts = cleanCounts(current?.data) + counts[id] = (counts[id] ?? 0) + 1 + const options = current ? { onlyIfMatch: current.etag } : { onlyIfNew: true } + const { modified } = await store.setJSON(COUNTS, counts, options) + if (modified) return counts[id] + } + return null +} + +export const handleKudos = async (req, { store, ip = '', secret = '', now = Date.now() }) => { + if (req.method === 'GET') { + const current = await store.get(COUNTS, { type: 'json' }) + return json({ counts: cleanCounts(current) }) + } + if (req.method !== 'POST') return json({ error: 'Method not allowed' }, 405, { allow: 'GET, POST' }) + // Only from the site's own pages (browsers send this on every fetch) + const site = req.headers.get('sec-fetch-site') + if (site && site !== 'same-origin') return json({ error: 'Forbidden' }, 403) + + const raw = await req.text() + if (raw.length > MAX_BODY) return json({ error: 'Too large' }, 413) + let id + try { + id = JSON.parse(raw)?.id + } catch { + return json({ error: 'Bad request' }, 400) + } + if (typeof id !== 'string' || !XP_IDS.has(id)) return json({ error: 'Unknown entry' }, 404) + + const day = new Date(now).toISOString().slice(0, 10) + const visitor = createHmac('sha256', `kudos:${secret}`).update(`${ip}\n${id}\n${day}`).digest('hex') + const marker = `seen/${day}/${visitor}` + const { modified: fresh } = await store.set(marker, '1', { onlyIfNew: true }) + if (!fresh) { + const current = await store.get(COUNTS, { type: 'json' }) + return json({ id, count: cleanCounts(current)[id] ?? 0, counted: false }) + } + + const count = await addOne(store, id) + if (count === null) { + // Too busy to count it: let the visitor try again + await store.delete(marker) + return json({ error: 'Busy, try again' }, 503) + } + return json({ id, count, counted: true }) +} + +export default async (req, context) => + handleKudos(req, { + store: getStore({ name: 'kudos', consistency: 'strong' }), + ip: context?.ip ?? '', + secret: process.env.GITHUB_CLIENT_SECRET ?? '' + }) + +export const config = { path: '/api/kudos' } diff --git a/package-lock.json b/package-lock.json index 3a63107..2f8c2e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "hanselwei.dev", "version": "2.0.0", "dependencies": { + "@netlify/blobs": "^11.1.6", "@pinia/nuxt": "^1.0.3", "daisyui": "^3.9.4", "marked": "^14.1.4", @@ -711,6 +712,19 @@ "integrity": "sha512-/B8YJGPzaYq1NbsQmwgP8EZqg40NpTw4ZB3suuI0TplbxKHeK94jeaawLmVhCv+YwUnOpiWEz9U6SeThku/8JQ==", "license": "MIT" }, + "node_modules/@envelop/instrumentation": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@envelop/instrumentation/-/instrumentation-1.0.1.tgz", + "integrity": "sha512-FuexE2qd3Yc1Kh1DjTliwGxb9kyyv4ySBNnfJOxnQasVmGdcqX6AkkO5sIMxLWFRftp8PErOcIn2dQW/1RwytQ==", + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.2.1", + "tslib": "^2.5.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -1127,6 +1141,12 @@ "node": ">=18" } }, + "node_modules/@fastify/busboy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.4.tgz", + "integrity": "sha512-lQS8xcXT54rdJb+wlG/Q7SMSdtkZEpLPHooSDUZyyZm9LJD7X1kONPhVoPaHq+dsPtWUGGLIz9gBNhs9qKbylw==", + "license": "MIT" + }, "node_modules/@ioredis/commands": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", @@ -1324,6 +1344,81 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@netlify/blobs": { + "version": "11.1.6", + "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-11.1.6.tgz", + "integrity": "sha512-7CKpZs4WbMqMvHRxXpQw395X4YBw9keGy3yDr7+pXjAgHlDO7kdYv3chkBYRc78K6delj7Mrmn2NB1HK2E5DxQ==", + "license": "MIT", + "dependencies": { + "@netlify/dev-utils": "6.0.4", + "@netlify/otel": "^7.0.6", + "@netlify/runtime-utils": "3.0.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@netlify/dev-utils": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@netlify/dev-utils/-/dev-utils-6.0.4.tgz", + "integrity": "sha512-VZgq2gqRLQual8P2v6B8/W1WgUNYJvVvgqCIbviPR/BcI6hKvxVBdAMjz+84eU3qpfMau1Bn2J70JDSQSNkNgg==", + "license": "MIT", + "dependencies": { + "@whatwg-node/server": "^0.11.0", + "ansis": "^4.1.0", + "atomically": "^2.0.3", + "chokidar": "^5.0.0", + "decache": "^4.6.2", + "dettle": "^1.0.5", + "dot-prop": "9.0.0", + "empathic": "^2.0.0", + "env-paths": "^3.0.0", + "parse-gitignore": "^2.0.0", + "semver": "^7.7.2" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@netlify/dev-utils/node_modules/dot-prop": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-9.0.0.tgz", + "integrity": "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^4.18.2" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@netlify/dev-utils/node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@netlify/dev-utils/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@netlify/functions": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@netlify/functions/-/functions-6.0.2.tgz", @@ -1337,6 +1432,22 @@ "node": ">=22.12.0" } }, + "node_modules/@netlify/otel": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-7.0.6.tgz", + "integrity": "sha512-PUE0NwWiXU2xh64WNb2y5cFFjkn3CvMH5ReL1zL8M9cue3BmEpqpCb1VPdsnrMtZdatjqmGDND8ACmGdrR/THg==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "1.9.1", + "@opentelemetry/core": "2.12.0", + "@opentelemetry/instrumentation": "^0.223.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/sdk-trace-node": "2.12.0" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@netlify/plugin-a11y": { "version": "1.0.0-beta.1", "resolved": "https://registry.npmjs.org/@netlify/plugin-a11y/-/plugin-a11y-1.0.0-beta.1.tgz", @@ -1476,6 +1587,15 @@ "node": ">=18" } }, + "node_modules/@netlify/runtime-utils": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@netlify/runtime-utils/-/runtime-utils-3.0.0.tgz", + "integrity": "sha512-OHo40+V3QZkR+UrDjqhN+LekrAby/dSSJxsvyYs9i88E/mSl3goB/ysoL+VYROm190egxmQyRYlHK7vL4GeHBQ==", + "license": "MIT", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@netlify/types": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@netlify/types/-/types-3.2.0.tgz", @@ -2434,6 +2554,150 @@ "unctx": "^2.4.1" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.223.0.tgz", + "integrity": "sha512-Oepm6etVPFCn0VWB1t833iOTgz1eYBDUPxcrOR/XvwzD1+Ag9VzSPSrrKAFNJ/haatj+8CXbG47pZXvdNQKDJw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.12.0.tgz", + "integrity": "sha512-iK/1nD7mmZVkuYjVjkdFBiCvzntJdFny1S3CYbUYyOq7V5enL6XX+gJ7eh965L1LnS11VA8mQ7Ej/ck04SB0MQ==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.12.0.tgz", + "integrity": "sha512-1HsSAuvT4/my0QrXWsyXFdWjKaPeCHsismKZgEEIb8NK13LP6DrMRlsUbvD/0F9mDWK3xDom9AMv3IscxgOnOQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.223.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.223.0.tgz", + "integrity": "sha512-tttBo88TbwK1zpsH6VeJoxLQopxOjW3cnrP50fsQJRZftkSKDDwjolXXBvow2jy0LKXlNRkDQh0NT6yI/q+BEQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.223.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.12.0.tgz", + "integrity": "sha512-+rSRoOdln6NhXTVcBdHSlZxJDNzwxadeweK2ltZO3tVtfp6aklABc06xclDk6ovRR+EiGgIcSbh/6NgjFhnb3A==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.12.0.tgz", + "integrity": "sha512-ImVuRQ6faOsTi1Wna88s2avgrrf/iDgXPigIeynkUhjstMuoQUDOJXgkez2j1mlU4btjXcXBGa+q7qKFYR3GsA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.12.0.tgz", + "integrity": "sha512-JOERgZCxRM06FEJ5kEWxlSRChah6MmXDx3KwCruqEv/iEyLkTokCSemf2y1w8oIHtfrr8EVTpzebq/31vn2Cpg==", + "deprecated": "Use @opentelemetry/sdk-trace package", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.12.0", + "@opentelemetry/resources": "2.12.0", + "@opentelemetry/sdk-trace": "2.12.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-node": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.12.0.tgz", + "integrity": "sha512-9DufH1IlmQMoKAfot5y/FhTP1RnmhLb5K6eKmJO/bvzPjYwPAazoiAUbTZFdPbqxOXN0FEbhZx/e6SB1Og4jmg==", + "deprecated": "Use @opentelemetry/sdk-trace package", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/context-async-hooks": "2.12.0", + "@opentelemetry/core": "2.12.0", + "@opentelemetry/sdk-trace-base": "2.12.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@parcel/watcher": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", @@ -4451,6 +4715,75 @@ "integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==", "license": "MIT" }, + "node_modules/@whatwg-node/disposablestack": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/disposablestack/-/disposablestack-0.0.6.tgz", + "integrity": "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw==", + "license": "MIT", + "dependencies": { + "@whatwg-node/promise-helpers": "^1.0.0", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/fetch": { + "version": "0.10.13", + "resolved": "https://registry.npmjs.org/@whatwg-node/fetch/-/fetch-0.10.13.tgz", + "integrity": "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q==", + "license": "MIT", + "dependencies": { + "@whatwg-node/node-fetch": "^0.8.3", + "urlpattern-polyfill": "^10.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/node-fetch": { + "version": "0.8.6", + "resolved": "https://registry.npmjs.org/@whatwg-node/node-fetch/-/node-fetch-0.8.6.tgz", + "integrity": "sha512-BDMdYFcerLQkwA2RTldxOqRCs6ZQD1S7UgP3pUdGUkcbgTrP/V5ko77ZkCww9DHmC4lpoYuwigGfQYj285gMvA==", + "license": "MIT", + "dependencies": { + "@fastify/busboy": "^3.1.1", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@whatwg-node/promise-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@whatwg-node/promise-helpers/-/promise-helpers-1.3.2.tgz", + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@whatwg-node/server": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@whatwg-node/server/-/server-0.11.0.tgz", + "integrity": "sha512-VSdkwnJRr8Yv9UgB2aXB3VUPWwd6Oqnn0hycFwhg9pZgWxJXb7JmhsiXe9tmpMwjHFxli12PGcz9aI63YYloGQ==", + "license": "MIT", + "dependencies": { + "@envelop/instrumentation": "^1.0.0", + "@whatwg-node/disposablestack": "^0.0.6", + "@whatwg-node/fetch": "^0.10.13", + "@whatwg-node/promise-helpers": "^1.3.2", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/abbrev": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", @@ -4903,6 +5236,16 @@ "node": ">= 4.0.0" } }, + "node_modules/atomically": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/atomically/-/atomically-2.1.1.tgz", + "integrity": "sha512-P4w9o2dqARji6P7MHprklbfiArZAWvo07yW7qs3pdljb3BWr12FIB7W+p0zJiuiVsUpRO0iZn1kFFcpPegg0tQ==", + "license": "MIT", + "dependencies": { + "stubborn-fs": "^2.0.0", + "when-exit": "^2.1.4" + } + }, "node_modules/autoprefixer": { "version": "10.6.1", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.6.1.tgz", @@ -5502,6 +5845,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/callsite": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/callsite/-/callsite-1.0.0.tgz", + "integrity": "sha512-0vdNRFXn5q+dtOqjfFtmtlI9N2eVZ7LMyEV2iKC5mEEFvSg/69Ml6b/WU2qF8W1nLRa0wiSrDT3Y5jOHZCwKPQ==", + "engines": { + "node": "*" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5691,6 +6042,12 @@ "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", "license": "MIT" }, + "node_modules/cjs-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.3.0.tgz", + "integrity": "sha512-lmNyBzi6iYiqyrExTctfmjd8tInx+dHu9PBomTfH5RXS53c27JMFJgzNv5TyAnm3PgGf+MLy0+Ws41/HlVxdKQ==", + "license": "MIT" + }, "node_modules/clean-css": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", @@ -6386,6 +6743,15 @@ } } }, + "node_modules/decache": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/decache/-/decache-4.6.2.tgz", + "integrity": "sha512-2LPqkLeu8XWHU8qNCS3kcF6sCcb5zIzvWaAHYSvPfwhdd7mHuah29NssMzrTYyHN4F5oFy2ko9OBYxegtU0FEw==", + "license": "MIT", + "dependencies": { + "callsite": "^1.0.0" + } + }, "node_modules/deep-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", @@ -6514,6 +6880,12 @@ "node": ">=8" } }, + "node_modules/dettle": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/dettle/-/dettle-1.0.5.tgz", + "integrity": "sha512-ZVyjhAJ7sCe1PNXEGveObOH9AC8QvMga3HJIghHawtG7mE4K5pW9nz/vDGAr/U7a3LWgdOzEE7ac9MURnyfaTA==", + "license": "MIT" + }, "node_modules/devalue": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/devalue/-/devalue-6.0.3.tgz", @@ -6713,6 +7085,15 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "license": "MIT" }, + "node_modules/empathic": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.1.0.tgz", + "integrity": "sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -8175,6 +8556,20 @@ "node": ">=4" } }, + "node_modules/import-in-the-middle": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.5.2.tgz", + "integrity": "sha512-WseIA/4o56+GYO3RmlOPvejYi771OFeJEeK/ejROsr9bdjW+E5NXTOD1Di8TL40Qgg3JJX9aa97Hf8iiSVIKZw==", + "license": "Apache-2.0", + "dependencies": { + "cjs-module-lexer": "^2.2.0", + "es-module-lexer": "^3.0.2", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/impound": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/impound/-/impound-1.2.1.tgz", @@ -9955,6 +10350,12 @@ "integrity": "sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA==", "license": "MIT" }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -10995,6 +11396,15 @@ "integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==", "dev": true }, + "node_modules/parse-gitignore": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-gitignore/-/parse-gitignore-2.0.0.tgz", + "integrity": "sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -12266,6 +12676,19 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -13295,6 +13718,21 @@ "integrity": "sha512-10ZL5r77LhknxlP1FBiCW+VdnuWOEFLdSS2SKtjyEV+L4qP1hUEIMIZW94LC3jKxRmT/Dj7KkD1mqh/IY6lhKQ==", "license": "ISC" }, + "node_modules/stubborn-fs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz", + "integrity": "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA==", + "license": "MIT", + "dependencies": { + "stubborn-utils": "^1.0.1" + } + }, + "node_modules/stubborn-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/stubborn-utils/-/stubborn-utils-1.0.2.tgz", + "integrity": "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg==", + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -13842,7 +14280,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tsscmp": { @@ -14454,6 +14891,12 @@ "integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==", "license": "MIT" }, + "node_modules/urlpattern-polyfill": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.1.0.tgz", + "integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==", + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -14951,6 +15394,12 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/when-exit": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", + "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", + "license": "MIT" + }, "node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", diff --git a/package.json b/package.json index 493fcdc..53d2961 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "node": ">=24" }, "dependencies": { + "@netlify/blobs": "^11.1.6", "@pinia/nuxt": "^1.0.3", "daisyui": "^3.9.4", "marked": "^14.1.4", diff --git a/public/sw.js b/public/sw.js index 74eec0a..6c51011 100644 --- a/public/sw.js +++ b/public/sw.js @@ -2,7 +2,7 @@ // - Build assets (/_nuxt/*, icons) are cache-first: their names are content-hashed. // - Pages are network-first, falling back to the last cached copy when offline. // - API responses (/api/*) are network-first so the gear inventory stays fresh. Sign-in, /api/me -// and the Bio photo bypass the worker entirely (never cached). +// the Bio photo and the live kudos counts bypass the worker entirely (never cached). const VERSION = 'v8' const STATIC_CACHE = `static-${VERSION}` const RUNTIME_CACHE = `runtime-${VERSION}` @@ -62,6 +62,8 @@ self.addEventListener('fetch', event => { // Never cache sign-in traffic or who's signed in, or the Bio photo (served no-store, only to the page) if (url.pathname.startsWith('/.netlify/') || url.pathname.startsWith('/api/auth/') || url.pathname === '/api/me') return if (url.pathname === '/api/bio-photo') return + // Live kudos counts: always the network's, never an old copy + if (url.pathname === '/api/kudos') return if (url.pathname.startsWith('/_nuxt/') || url.pathname.startsWith('/icons/')) { event.respondWith(cacheFirst(request)) diff --git a/tests/e2e/a11y.spec.mjs b/tests/e2e/a11y.spec.mjs index d994666..43a9ddf 100644 --- a/tests/e2e/a11y.spec.mjs +++ b/tests/e2e/a11y.spec.mjs @@ -72,3 +72,34 @@ test('the Instagram menu has none when open', async ({ page }) => { await expect(page.locator('.flip-menu')).toBeVisible({ timeout: 3000 }) await check(page, 'the open Instagram menu') }) + +// Kudos on the plain-English XP list (netlify/functions/kudos.mjs, stood in for here): the +// buttons have no violations, and a tap counts without flipping the card back to JSON. The +// service worker is off so the stand-in sees the requests. +test.describe('XP kudos', () => { + test.use({ serviceWorkers: 'block' }) + + test('the kudos buttons have none, and a tap counts without leaving plain English', async ({ page }) => { + const sent = [] + await page.route('**/api/kudos', async route => { + const request = route.request() + if (request.method() === 'POST') sent.push(request.postDataJSON().id) + const body = request.method() === 'POST' ? { id: sent.at(-1), count: 1, counted: true } : { counts: {} } + await route.fulfill({ status: 200, contentType: 'application/json; charset=utf-8', body: JSON.stringify(body) }) + }) + await open(page, '/#xp') + await page.locator('.about-card [role="tabpanel"]').click({ modifiers: ['Alt'] }) + await expect(page.locator('.poof-word')).toHaveCount(0, { timeout: 5000 }) + const button = page.locator('.edu-list .kudos').first() + await expect(button).toBeVisible({ timeout: 5000 }) + await check(page, 'the XP list with kudos') + + await button.click() + await expect(button).toHaveClass(/kudos--given/) + await expect(button).toContainText('1') + expect(sent).toHaveLength(1) + await expect(page.locator('.edu-list')).toBeVisible() + await settled(page) + await check(page, 'the XP list after a kudos') + }) +}) diff --git a/tests/security/kudos.test.mjs b/tests/security/kudos.test.mjs new file mode 100644 index 0000000..50fb11e --- /dev/null +++ b/tests/security/kudos.test.mjs @@ -0,0 +1,94 @@ +// Kudos on the About card's XP entries (netlify/functions/kudos.mjs): only known entries count, +// once per visitor per entry per day, hostile bodies and stored data can't add keys or crash it, +// and kudos sent at the same moment are all counted +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { handleKudos } from '../../netlify/functions/kudos.mjs' +import { XP_IDS } from '../../data/xp.mjs' +import { KUDOS_BODIES, PROTOTYPE_KEYS, XSS_HTML } from './payloads.mjs' + +// An in-memory stand-in for a Netlify Blobs store, with its conditional writes +const memoryStore = () => { + const blobs = new Map() + let version = 0 + const put = (key, text, { onlyIfNew, onlyIfMatch } = {}) => { + const current = blobs.get(key) + if (onlyIfNew && current) return { modified: false } + if (onlyIfMatch && current?.etag !== onlyIfMatch) return { modified: false } + const etag = `"${++version}"` + blobs.set(key, { text, etag }) + return { modified: true, etag } + } + const read = (key, type) => { + const blob = blobs.get(key) + if (!blob) return null + return type === 'json' ? JSON.parse(blob.text) : blob.text + } + return { + blobs, + get: async (key, { type } = {}) => read(key, type), + getWithMetadata: async (key, { type } = {}) => (blobs.has(key) ? { data: read(key, type), etag: blobs.get(key).etag, metadata: {} } : null), + set: async (key, text, options) => put(key, String(text), options), + setJSON: async (key, value, options) => put(key, JSON.stringify(value), options), + delete: async key => void blobs.delete(key) + } +} + +const ID = 'education-ytt200' +const post = (body, headers = {}) => new Request('https://www.hanselwei.dev/api/kudos', { method: 'POST', headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin', ...headers }, body }) +const get = () => new Request('https://www.hanselwei.dev/api/kudos') + +test('every XP entry has a unique id, and the ids stay the same shape', () => { + assert.ok(XP_IDS.has(ID)) + for (const id of XP_IDS) assert.match(id, /^(education|work|volunteer|achievement)-[a-z0-9]+(?:-[a-z0-9]+)*$/, id) +}) + +test('a kudos counts once per visitor per entry per day', async () => { + const store = memoryStore() + const send = (ip, now = Date.UTC(2026, 9, 25, 12)) => handleKudos(post(JSON.stringify({ id: ID })), { store, ip, secret: 's', now }) + assert.deepEqual(await (await send('1.1.1.1')).json(), { id: ID, count: 1, counted: true }) + assert.deepEqual(await (await send('1.1.1.1')).json(), { id: ID, count: 1, counted: false }) + assert.deepEqual(await (await send('2.2.2.2')).json(), { id: ID, count: 2, counted: true }) + assert.deepEqual(await (await send('1.1.1.1', Date.UTC(2026, 9, 26, 1))).json(), { id: ID, count: 3, counted: true }) + assert.deepEqual(await (await handleKudos(get(), { store })).json(), { counts: { [ID]: 3 } }) + // The markers don't hold anyone's IP address + for (const key of store.blobs.keys()) assert.doesNotMatch(key, /\d+\.\d+\.\d+\.\d+/) +}) + +test('kudos sent at the same moment are all counted', async () => { + const store = memoryStore() + const results = await Promise.all(Array.from({ length: 5 }, (_, i) => handleKudos(post(JSON.stringify({ id: ID })), { store, ip: `10.0.0.${i}` }))) + assert.ok(results.every(r => r.status === 200)) + assert.equal((await store.get('counts', { type: 'json' }))[ID], 5) +}) + +test('hostile bodies and unknown ids are refused and never stored', async () => { + const store = memoryStore() + for (const body of [...KUDOS_BODIES, ...XSS_HTML.map(id => JSON.stringify({ id })), ...PROTOTYPE_KEYS.map(id => JSON.stringify({ id }))]) { + const res = await handleKudos(post(body), { store, ip: '1.1.1.1' }) + assert.ok([400, 404].includes(res.status), `${body.slice(0, 50)} -> ${res.status}`) + assert.equal(res.headers.get('content-type'), 'application/json; charset=utf-8') + await res.json() + } + assert.equal(store.blobs.size, 0) + assert.equal((await handleKudos(post('x'.repeat(2000)), { store })).status, 413) +}) + +test('only the site\'s own pages can send kudos, and only by GET or POST', async () => { + const store = memoryStore() + for (const site of ['cross-site', 'same-site', 'none']) { + assert.equal((await handleKudos(post(JSON.stringify({ id: ID }), { 'sec-fetch-site': site }), { store })).status, 403, site) + } + for (const method of ['PUT', 'DELETE', 'PATCH']) { + assert.equal((await handleKudos(new Request('https://www.hanselwei.dev/api/kudos', { method }), { store })).status, 405, method) + } + assert.equal(store.blobs.size, 0) +}) + +test('stored counts are cleaned: unknown keys, prototype keys and non-counts are dropped', async () => { + const store = memoryStore() + store.blobs.set('counts', { etag: '"x"', text: JSON.stringify({ [ID]: 2, 'education-unknown': 9, __proto__: { polluted: 1 }, constructor: 5, 'achievement-around-the-crown-10k': -1, 'work-career-break-apr-2024': '3', 'achievement-mlh-hacknc-2018': 1.5 }) }) + const { counts } = await (await handleKudos(get(), { store })).json() + assert.deepEqual(counts, { [ID]: 2 }) + assert.equal({}.polluted, undefined) +}) diff --git a/tests/security/payloads.mjs b/tests/security/payloads.mjs index 0e66944..d72aa6a 100644 --- a/tests/security/payloads.mjs +++ b/tests/security/payloads.mjs @@ -135,3 +135,25 @@ export const OUTBOUND_IDS = [ '%6Dastodon', '' ] + +// POST /api/kudos bodies (netlify/functions/kudos.mjs): only a known XP entry id counts +export const KUDOS_BODIES = [ + '', + 'null', + '[]', + '"education-ytt200"', + '{"id":null}', + '{"id":1}', + '{"id":["education-ytt200"]}', + '{"id":{"toString":"education-ytt200"}}', + '{"id":"__proto__"}', + '{"__proto__":{"id":"education-ytt200"}}', + '{"id":"constructor"}', + '{"id":"education-ytt200 "}', + '{"id":"EDUCATION-YTT200"}', + '{"id":"../counts"}', + '{"id":"counts"}', + '{"id":"seen/2026-10-25/x"}', + '{"id":""}', + '{id:"education-ytt200"}' +]