From 814957cd37ccabf24a45eda207f88de7fa0a724a Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 17 Mar 2026 00:47:42 +0000 Subject: [PATCH] feat: add eslint, image upload, tests, splash tagline, security hooks - Restore CLAUDE.md with project conventions - ESLint config with vue3-recommended + typescript - Image upload endpoint (POST /api/admin/upload) with 5MB limit - Admin product form now supports image upload/preview/removal - Vitest config + 19 tests (crypto, validation, btcpay webhook, types) - Restore .claude/ security hooks (block-risky-bash, protect-files) - Logo splash now shows "EVERYTHING YOU LOVE IS A PSYOP" tagline - Add .vite/ to gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/hooks/block-risky-bash.sh | 32 +++ .claude/hooks/protect-files.sh | 46 ++++ .claude/settings.json | 24 +++ .gitignore | 1 + CLAUDE.md | 125 +++++++++++ eslint.config.js | 47 +++++ package.json | 45 ++-- pnpm-lock.yaml | 301 +++++++++++++++++++++++++++ server/index.ts | 2 + server/routes/upload.ts | 114 ++++++++++ src/components/splash/LogoSplash.vue | 146 +++++++------ src/views/admin/ProductFormView.vue | 225 ++++++++++++++++++-- tests/server/btcpay.test.ts | 38 ++++ tests/server/crypto.test.ts | 55 +++++ tests/server/validate.test.ts | 47 +++++ tests/shared/types.test.ts | 52 +++++ vitest.config.ts | 15 ++ 17 files changed, 1199 insertions(+), 116 deletions(-) create mode 100755 .claude/hooks/block-risky-bash.sh create mode 100755 .claude/hooks/protect-files.sh create mode 100644 .claude/settings.json create mode 100644 CLAUDE.md create mode 100644 eslint.config.js create mode 100644 server/routes/upload.ts create mode 100644 tests/server/btcpay.test.ts create mode 100644 tests/server/crypto.test.ts create mode 100644 tests/server/validate.test.ts create mode 100644 tests/shared/types.test.ts create mode 100644 vitest.config.ts diff --git a/.claude/hooks/block-risky-bash.sh b/.claude/hooks/block-risky-bash.sh new file mode 100755 index 0000000..326e623 --- /dev/null +++ b/.claude/hooks/block-risky-bash.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Block dangerous bash commands + +INPUT=$(cat) +COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // empty') + +if [ -z "$COMMAND" ]; then + exit 0 +fi + +# Block destructive operations +BLOCKED_PATTERNS=( + "rm -rf" + "git reset --hard" + "git push -f" + "git push --force" + "git clean -fd" + "chmod -R 777" + ":(){ :|:& };:" + "mkfs" + "> /dev/" + "dd if=" +) + +for pattern in "${BLOCKED_PATTERNS[@]}"; do + if echo "$COMMAND" | grep -qF "$pattern"; then + echo "Destructive ${pattern%% *} blocked by security hook" >&2 + exit 2 + fi +done + +exit 0 diff --git a/.claude/hooks/protect-files.sh b/.claude/hooks/protect-files.sh new file mode 100755 index 0000000..29ac1f4 --- /dev/null +++ b/.claude/hooks/protect-files.sh @@ -0,0 +1,46 @@ +#!/bin/bash +# Protect sensitive files from being edited + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // .tool_input.path // empty') + +if [ -z "$FILE_PATH" ]; then + exit 0 +fi + +# Get the project directory +PROJECT_DIR="$CLAUDE_PROJECT_DIR" +if [ -z "$PROJECT_DIR" ]; then + PROJECT_DIR="$(pwd)" +fi + +# Block edits to .git internals +if echo "$FILE_PATH" | grep -q '\.git/'; then + echo "Edit blocked: path matches protected pattern (.git/)" >&2 + exit 2 +fi + +# Block .env files +if echo "$FILE_PATH" | grep -qE '\.env($|\.)'; then + echo "Edit blocked: path matches protected pattern (.env)" >&2 + exit 2 +fi + +# Block node_modules +if echo "$FILE_PATH" | grep -q 'node_modules/'; then + echo "Edit blocked: path matches protected pattern (node_modules/)" >&2 + exit 2 +fi + +# Block files outside project directory +REAL_PROJECT=$(cd "$PROJECT_DIR" 2>/dev/null && pwd -P) +REAL_FILE_DIR=$(cd "$(dirname "$FILE_PATH")" 2>/dev/null && pwd -P) + +if [ -n "$REAL_PROJECT" ] && [ -n "$REAL_FILE_DIR" ]; then + case "$REAL_FILE_DIR" in + "$REAL_PROJECT"*) ;; + *) echo "Edit blocked: path is outside project directory" >&2; exit 2 ;; + esac +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..73207c4 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,24 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-risky-bash.sh" + } + ] + }, + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/protect-files.sh" + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index 15ee92f..7e1adac 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ __pycache__/ .DS_Store loop/loop.log data/ +.vite/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7be32ea --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,125 @@ +# CLAUDE.md -- Antonym + +## Core Philosophy + +- **Open source only** -- MIT/Apache-2.0 dependencies +- **Privacy-first** -- no tracking, no telemetry +- **Bitcoin only** -- sats/Lightning/Cashu for payments, never fiat, never altcoins +- **Quality over speed** -- working code, tested, documented + +## Quick Reference + +```bash +pnpm dev # Run app dev server + API server +pnpm dev:app # Vite dev server only +pnpm dev:server # Express API only +pnpm build # Build frontend (vite) +pnpm typecheck # Type-check all packages (vue-tsc + tsc) +pnpm lint # Lint all packages (eslint) +pnpm test # Run tests (vitest) +pnpm clean # Remove dist/ directory +``` + +Dev server: `http://localhost:3333` | API server: `http://localhost:3141` + +## Vue 3 Conventions + +**Always use ` @@ -37,63 +43,30 @@ onMounted(() => { class="splash-overlay" :class="{ 'splash-fade-out': isFading }" > - +
+ + +

+ EVERYTHING YOU LOVE IS A PSYOP +

+
@@ -114,6 +87,13 @@ onMounted(() => { pointer-events: none; } +.splash-content { + display: flex; + flex-direction: column; + align-items: center; + gap: 2.5rem; +} + .splash-logo { width: min(70vw, 400px); height: auto; @@ -133,21 +113,31 @@ onMounted(() => { } @keyframes revealPath { - from { - clip-path: inset(0 100% 0 0); - } - to { - clip-path: inset(0 0 0 0); - } + from { clip-path: inset(0 100% 0 0); } + to { clip-path: inset(0 0 0 0); } } @keyframes fadeInPath { - from { - opacity: 0; - } - to { - opacity: 1; - } + from { opacity: 0; } + to { opacity: 1; } +} + +.tagline { + font-family: system-ui, -apple-system, 'Helvetica Neue', sans-serif; + font-size: clamp(0.75rem, 2.5vw, 1.125rem); + font-weight: 900; + letter-spacing: 0.25em; + text-transform: uppercase; + color: var(--text-primary); + opacity: 0; + transform: translateY(12px); + transition: opacity 600ms ease, transform 600ms cubic-bezier(0.16, 1, 0.3, 1); + white-space: nowrap; +} + +.tagline.visible { + opacity: 1; + transform: translateY(0); } @media (prefers-reduced-motion: reduce) { @@ -156,6 +146,12 @@ onMounted(() => { opacity: 1; } + .tagline { + opacity: 1; + transform: none; + transition: none; + } + .splash-overlay { transition-duration: 0ms; } diff --git a/src/views/admin/ProductFormView.vue b/src/views/admin/ProductFormView.vue index 07f8fc3..95bb599 100644 --- a/src/views/admin/ProductFormView.vue +++ b/src/views/admin/ProductFormView.vue @@ -12,41 +12,129 @@ const router = useRouter() const isEdit = computed(() => route.name === 'admin-product-edit') const isLoading = ref(false) const isSaving = ref(false) +const isUploading = ref(false) const errorMessage = ref('') const name = ref('') const slug = ref('') const description = ref('') const priceSats = ref(0) const category = ref('general') -const sizes = ref([{ size: 'S', stock: 0 }, { size: 'M', stock: 0 }, { size: 'L', stock: 0 }, { size: 'XL', stock: 0 }]) +const images = ref([]) +const sizes = ref([ + { size: 'S', stock: 0 }, + { size: 'M', stock: 0 }, + { size: 'L', stock: 0 }, + { size: 'XL', stock: 0 }, +]) onMounted(async () => { if (isEdit.value && route.params.id) { isLoading.value = true try { - const res = await api.get(`/api/admin/products`) + const res = await api.get('/api/admin/products') if (res.ok) { const products = await api.json(res) const product = products.find((p) => p.id === route.params.id) - if (product) { name.value = product.name; slug.value = product.slug; description.value = product.description; priceSats.value = product.priceSats; category.value = product.category; sizes.value = product.sizes } + if (product) { + name.value = product.name + slug.value = product.slug + description.value = product.description + priceSats.value = product.priceSats + category.value = product.category + images.value = product.images + sizes.value = product.sizes + } } - } finally { isLoading.value = false } + } finally { + isLoading.value = false + } } }) -function addSize() { sizes.value.push({ size: '', stock: 0 }) } -function removeSize(index: number) { sizes.value.splice(index, 1) } -function autoSlug() { if (!isEdit.value) slug.value = name.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') } +function addSize() { + sizes.value.push({ size: '', stock: 0 }) +} + +function removeSize(index: number) { + sizes.value.splice(index, 1) +} + +function removeImage(index: number) { + images.value.splice(index, 1) +} + +function autoSlug() { + if (!isEdit.value) { + slug.value = name.value + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + } +} + +async function handleUpload(event: Event) { + const input = event.target as HTMLInputElement + const files = input.files + if (!files || files.length === 0) return + + isUploading.value = true + try { + const formData = new FormData() + for (const file of files) { + formData.append('images', file) + } + + const res = await fetch('/api/admin/upload', { + method: 'POST', + body: formData, + credentials: 'same-origin', + }) + + if (res.ok) { + const data = (await res.json()) as { urls: string[] } + images.value.push(...data.urls) + } else { + const err = (await res.json()) as ApiError + errorMessage.value = err.error.message + } + } catch { + errorMessage.value = 'Image upload failed' + } finally { + isUploading.value = false + input.value = '' + } +} async function handleSave() { - isSaving.value = true; errorMessage.value = '' - const data = { name: name.value, slug: slug.value, description: description.value, priceSats: priceSats.value, category: category.value, sizes: sizes.value, images: [] } + isSaving.value = true + errorMessage.value = '' + + const data = { + name: name.value, + slug: slug.value, + description: description.value, + priceSats: priceSats.value, + category: category.value, + sizes: sizes.value, + images: images.value, + } + try { - const res = isEdit.value ? await api.put(`/api/admin/products/${route.params.id}`, data) : await api.post('/api/admin/products', data) - if (res.ok) router.push({ name: 'admin-products' }) - else { const err = await api.json(res); errorMessage.value = err.error.message } - } catch { errorMessage.value = 'Failed to save product' } - finally { isSaving.value = false } + const res = isEdit.value + ? await api.put(`/api/admin/products/${route.params.id}`, data) + : await api.post('/api/admin/products', data) + + if (res.ok) { + router.push({ name: 'admin-products' }) + } else { + const err = await api.json(res) + errorMessage.value = err.error.message + } + } catch { + errorMessage.value = 'Failed to save product' + } finally { + isSaving.value = false + } } @@ -54,25 +142,71 @@ async function handleSave() {
← Products

{{ isEdit ? 'Edit Product' : 'New Product' }}

+ +
-
-
-
-
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-